Compare commits
51 Commits
core/1.44
...
codex/webs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f6e0f8b74 | ||
|
|
74caeb0b0b | ||
|
|
ced7c93e63 | ||
|
|
759ed3d4e2 | ||
|
|
5d53e75d23 | ||
|
|
d23e86d9a4 | ||
|
|
d901c63a0b | ||
|
|
5ca9f3e7e6 | ||
|
|
6d5fa743b3 | ||
|
|
603dd3eb4e | ||
|
|
d767a325a2 | ||
|
|
39b2bb5eab | ||
|
|
c643438601 | ||
|
|
02e1ba2968 | ||
|
|
15b8771cc2 | ||
|
|
e68d50e677 | ||
|
|
48b5e0165a | ||
|
|
fe1de3b254 | ||
|
|
1c2ae70343 | ||
|
|
8f68be5699 | ||
|
|
653ef1a4f0 | ||
|
|
c16052e2e3 | ||
|
|
3e94459340 | ||
|
|
ca54877f9d | ||
|
|
a4faaa0159 | ||
|
|
8108967d49 | ||
|
|
0ef98de8eb | ||
|
|
88866fc564 | ||
|
|
1f4a4af079 | ||
|
|
c8c0e53865 | ||
|
|
c8360a092f | ||
|
|
68843967cf | ||
|
|
8c295e7c68 | ||
|
|
219a574eed | ||
|
|
fef2cab31e | ||
|
|
20ee262f78 | ||
|
|
6a8c453659 | ||
|
|
ea277dec4d | ||
|
|
a7aa124c10 | ||
|
|
9c62bbc74a | ||
|
|
f0e16cdf46 | ||
|
|
0658c1ac9c | ||
|
|
997501d8fb | ||
|
|
ab6e5ba094 | ||
|
|
2322a5a497 | ||
|
|
0bc951fd12 | ||
|
|
0446ca7a18 | ||
|
|
653ee48444 | ||
|
|
81d9df61f2 | ||
|
|
f4358cb161 | ||
|
|
5948002dee |
@@ -19,15 +19,26 @@ reviews:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
|
||||
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
Use only PR metadata already available in the review context:
|
||||
- the PR title
|
||||
- commit subjects in this PR
|
||||
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
|
||||
- the PR description.
|
||||
Do not rely on shell commands.
|
||||
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
|
||||
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
|
||||
Pass if at least one of the following is true:
|
||||
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes at least one file under `browser_tests/`.
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
Fail if all of the following are true:
|
||||
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
|
||||
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
|
||||
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
|
||||
|
||||
Pass otherwise.
|
||||
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
|
||||
36
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -20,6 +20,8 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -37,31 +39,33 @@ jobs:
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Detect shard coverage data
|
||||
id: coverage-shards
|
||||
run: |
|
||||
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
|
||||
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Install lcov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
@@ -100,6 +104,7 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
@@ -114,6 +119,7 @@ jobs:
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
@@ -122,7 +128,9 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
28
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh apps/website data snapshots and open
|
||||
# a PR. Merging the PR triggers the existing Vercel website production deploy
|
||||
# via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
refresh-snapshots:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,15 +31,20 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh GitHub stars snapshot
|
||||
run: pnpm --filter @comfyorg/website github-stars:refresh-snapshot
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
commit-message: 'chore(website): refresh data snapshots'
|
||||
title: 'chore(website): refresh data snapshots'
|
||||
body: |
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
Automated refresh of website build-data snapshots:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
- `apps/website/src/data/github-stars.snapshot.json`
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
@@ -47,12 +52,11 @@ jobs:
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
The snapshot fallback paths remain intact: builds use committed
|
||||
snapshots whenever a live build-data source is unavailable.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
branch: chore/refresh-website-data-snapshots-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Marketing/brand website built with Astro + Vue.
|
||||
|
||||
## Build-time data sources
|
||||
|
||||
External data used during static generation should use the shared build-data
|
||||
source helpers in `src/utils/buildDataSource.ts` and
|
||||
`src/utils/buildDataReporter.ts`. Each source owns its domain parsing, but the
|
||||
shared machinery owns the repeated lifecycle:
|
||||
|
||||
1. Fetch fresh data once per build process.
|
||||
2. Validate and map to a committed snapshot shape.
|
||||
3. Fall back to the committed snapshot on fetch/schema/network failure.
|
||||
4. Emit GitHub Actions annotations and `$GITHUB_STEP_SUMMARY` rows once per
|
||||
build process.
|
||||
5. Refresh snapshots through package scripts that write atomically.
|
||||
|
||||
## Ashby careers integration
|
||||
|
||||
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
|
||||
@@ -113,6 +127,19 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## GitHub stars integration
|
||||
|
||||
The navigation star badge is rendered from the GitHub repository API at build
|
||||
time through the same snapshot-fallback lifecycle.
|
||||
|
||||
- Runtime source: `src/utils/github.ts`
|
||||
- Snapshot: `src/data/github-stars.snapshot.json`
|
||||
- CI reporter: `src/utils/github.ci.ts`
|
||||
- Refresh script: `pnpm --filter @comfyorg/website github-stars:refresh-snapshot`
|
||||
|
||||
`WEBSITE_GITHUB_STARS_OVERRIDE` remains available for visual snapshots and local
|
||||
determinism. It must be a non-negative integer and is build-time only.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
@@ -146,3 +173,5 @@ renders the documented embed container.
|
||||
- `pnpm test:unit` — Vitest unit tests
|
||||
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
|
||||
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
|
||||
- `pnpm github-stars:refresh-snapshot` — refresh the committed GitHub stars
|
||||
snapshot
|
||||
|
||||
@@ -32,16 +32,34 @@ test.describe('Careers page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('ENGINEERING category filter narrows the role list', async ({
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
page
|
||||
}) => {
|
||||
const rolesSection = page.getByTestId('careers-roles')
|
||||
await rolesSection.scrollIntoViewIfNeeded()
|
||||
await expect(rolesSection).toBeVisible()
|
||||
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
|
||||
const engineeringButton = page.getByRole('button', {
|
||||
name: 'ENGINEERING',
|
||||
exact: true
|
||||
})
|
||||
|
||||
// RolesSection is hydrated via `client:visible`. Once the button responds
|
||||
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
|
||||
// the locator logic is in effect.
|
||||
await expect(async () => {
|
||||
await engineeringButton.click()
|
||||
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
|
||||
timeout: 1_000
|
||||
})
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
const engineeringSection = page.locator('#careers-dept-engineering')
|
||||
await expect(engineeringSection).toBeInViewport()
|
||||
|
||||
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
61
apps/website/e2e/content-section.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
|
||||
const LAST_SECTION_HASH = '#contact'
|
||||
|
||||
test.describe(
|
||||
'ContentSection scroll-spy @smoke',
|
||||
{
|
||||
annotation: [
|
||||
{
|
||||
type: 'issue',
|
||||
description:
|
||||
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
|
||||
},
|
||||
{
|
||||
type: 'environment',
|
||||
description:
|
||||
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
|
||||
}
|
||||
]
|
||||
},
|
||||
() => {
|
||||
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
|
||||
|
||||
test('activates the last badge when user scrolls to the bottom', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/privacy-policy')
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const badges = sidebarNav.getByRole('button')
|
||||
const lastBadge = badges.last()
|
||||
|
||||
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo(0, document.documentElement.scrollHeight)
|
||||
)
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const lastBadge = sidebarNav.getByRole('button').last()
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
}
|
||||
)
|
||||
33
apps/website/e2e/customers.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customers @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers')
|
||||
})
|
||||
|
||||
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
|
||||
await expect(heroImage).toBeVisible()
|
||||
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
|
||||
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
|
||||
|
||||
// Regression guard: an unloaded <img> without intrinsic dimensions
|
||||
// collapses to ~0px, then jumps to its natural size on load and pushes
|
||||
// the video below it. Reserved space must persist before bytes arrive.
|
||||
const heightWhileUnloaded = await page.evaluate(() => {
|
||||
const img = document.querySelector<HTMLImageElement>(
|
||||
'img[alt="Comfy 3D logo"]'
|
||||
)
|
||||
if (!img) return null
|
||||
img.removeAttribute('src')
|
||||
return img.getBoundingClientRect().height
|
||||
})
|
||||
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,71 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { demos, getNextDemo } from '../src/config/demos'
|
||||
import { t } from '../src/i18n/translations'
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
test('demo detail page renders hero and embed', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'Create a Video from an Image'
|
||||
)
|
||||
const iframe = page.locator('iframe[title*="Interactive demo"]')
|
||||
await expect(iframe).toBeAttached()
|
||||
})
|
||||
for (const demo of demos) {
|
||||
const nextDemo = getNextDemo(demo.slug)
|
||||
|
||||
test('demo detail page has transcript section', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/demos/${demo.slug}`)
|
||||
|
||||
test('demo detail page has next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/i)).toBeVisible()
|
||||
})
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t(demo.title, 'en'))
|
||||
|
||||
const ogImage = page.locator('head meta[property="og:image"]')
|
||||
await expect(ogImage).toHaveAttribute(
|
||||
'content',
|
||||
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
|
||||
)
|
||||
|
||||
const iframe = page.locator(
|
||||
`iframe[title*="${t('demos.embed.label', 'en')}"]`
|
||||
)
|
||||
await expect(iframe).toBeAttached()
|
||||
await expect(iframe).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(escapeRegExp(demo.arcadeId))
|
||||
)
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'en')).first()
|
||||
).toBeVisible()
|
||||
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
|
||||
await expect(nextThumb).toBeAttached()
|
||||
await expect(nextThumb).toBeVisible()
|
||||
const naturalWidth = await nextThumb.evaluate(
|
||||
(img) => (img as HTMLImageElement).naturalWidth
|
||||
)
|
||||
expect(naturalWidth).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/zh-CN/demos/${demo.slug}`)
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
|
||||
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'zh-CN')).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test('demo library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
@@ -32,13 +76,4 @@ test.describe('Demo pages @smoke', () => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('zh-CN demo page renders localized content', async ({ page }) => {
|
||||
await page.goto('/zh-CN/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'从图片创建视频'
|
||||
)
|
||||
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
|
||||
await expect(nextDemoLink).toBeAttached()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
@@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => {
|
||||
const mobileContainer = page.getByTestId('social-proof-mobile')
|
||||
await expect(mobileContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('SocialProofBar seamless marquee', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test('mobile forward marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
|
||||
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
|
||||
)
|
||||
expectSeamlessReverseLoop(geometry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop SocialProofBar @smoke', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('desktop marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-desktop"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
})
|
||||
|
||||
type MarqueeGeometry = {
|
||||
copyWidths: number[]
|
||||
startPositions: number[]
|
||||
endPositions: number[]
|
||||
}
|
||||
|
||||
async function measureMarqueeLoopGeometry(
|
||||
page: Page,
|
||||
selector: string
|
||||
): Promise<MarqueeGeometry> {
|
||||
await page.locator(selector).first().waitFor()
|
||||
return page.evaluate((sel) => {
|
||||
const tracks = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(sel)
|
||||
).slice(0, 2)
|
||||
const firstAnimation = tracks[0]?.getAnimations()[0]
|
||||
if (!firstAnimation) {
|
||||
throw new Error(`No CSS animation found on ${sel}`)
|
||||
}
|
||||
const duration = firstAnimation.effect?.getTiming().duration
|
||||
if (typeof duration !== 'number' || duration <= 1) {
|
||||
throw new Error(
|
||||
`Animation on ${sel} has unusable duration: ${String(duration)}`
|
||||
)
|
||||
}
|
||||
const setAllTimes = (time: number) => {
|
||||
for (const track of tracks) {
|
||||
for (const anim of track.getAnimations()) {
|
||||
anim.currentTime = time
|
||||
}
|
||||
}
|
||||
void document.body.offsetWidth
|
||||
}
|
||||
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
|
||||
setAllTimes(0)
|
||||
const startPositions = readX()
|
||||
const copyWidths = tracks.map(
|
||||
(track) => track.getBoundingClientRect().width
|
||||
)
|
||||
setAllTimes(duration - 0.1)
|
||||
const endPositions = readX()
|
||||
return { copyWidths, startPositions, endPositions }
|
||||
}, selector)
|
||||
}
|
||||
|
||||
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
|
||||
const { copyWidths } = geometry
|
||||
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
|
||||
expect(copyWidths[0]).toBeGreaterThan(0)
|
||||
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
|
||||
// (when copy 1 jumps back to its start position) is visually indistinguishable.
|
||||
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
|
||||
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
|
||||
}
|
||||
|
||||
async function navigateAndSettle(page: Page, url: string) {
|
||||
await page.goto(url)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('load')
|
||||
}
|
||||
|
||||
test.describe('Home', { tag: '@visual' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 99 KiB |
@@ -15,7 +15,8 @@
|
||||
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
|
||||
"test:visual": "playwright test --project visual",
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"github-stars:refresh-snapshot": "tsx ./scripts/refresh-github-stars-snapshot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
BIN
apps/website/public/images/demos/community-workflows-og.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
apps/website/public/images/demos/community-workflows-thumb.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 20 KiB |
29
apps/website/scripts/refresh-github-stars-snapshot.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchGitHubStarsForBuild } from '../src/utils/github'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/github-stars.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
tempPath,
|
||||
JSON.stringify(outcome.snapshot, null, 2) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
renameSync(tempPath, snapshotPath)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${outcome.snapshot.stargazersCount} star(s) to ${snapshotPath}\n`
|
||||
)
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
@@ -13,24 +16,72 @@ const { locale = 'en', departments = [] } = defineProps<{
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
const categories = computed(() =>
|
||||
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
|
||||
const activeCategory = ref('')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
|
||||
let isScrolling = false
|
||||
let pendingFrame = 0
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const ACTIVATION_OFFSET = 300
|
||||
|
||||
const deptElementId = (key: string) => `careers-dept-${key}`
|
||||
|
||||
function pickActiveSection() {
|
||||
pendingFrame = 0
|
||||
if (isScrolling) return
|
||||
const sections = sectionRefs.value as HTMLElement[]
|
||||
if (sections.length === 0) return
|
||||
|
||||
let active = sections[0]
|
||||
for (const el of sections) {
|
||||
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
|
||||
active = el
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
activeCategory.value = active.id.replace(/^careers-dept-/, '')
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingFrame !== 0) return
|
||||
pendingFrame = requestAnimationFrame(pickActiveSection)
|
||||
}
|
||||
|
||||
onMounted(pickActiveSection)
|
||||
useEventListener('scroll', scheduleUpdate, { passive: true })
|
||||
useEventListener('resize', scheduleUpdate, { passive: true })
|
||||
|
||||
function scrollToDepartment(deptKey: string) {
|
||||
activeCategory.value = deptKey
|
||||
isScrolling = true
|
||||
const el = document.getElementById(deptElementId(deptKey))
|
||||
if (!el) {
|
||||
isScrolling = false
|
||||
return
|
||||
}
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
pickActiveSection()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,9 +99,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
:model-value="activeCategory"
|
||||
class="mt-4"
|
||||
@update:model-value="scrollToDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,9 +117,11 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in filteredDepartments"
|
||||
v-for="dept in visibleDepartments"
|
||||
:id="deptElementId(dept.key)"
|
||||
:ref="sectionRefs.set"
|
||||
:key="dept.key"
|
||||
class="mb-12 last:mb-0"
|
||||
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
|
||||
>
|
||||
<SectionLabel>
|
||||
{{ dept.name }}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useTemplateRefsList
|
||||
} from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
@@ -40,13 +44,25 @@ const activeSection = ref(sections[0]?.id ?? '')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
sectionRefs,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
@@ -58,22 +74,39 @@ useIntersectionObserver(
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = sections[sections.length - 1]?.id
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
onMounted(activateLastIfAtBottom)
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
}
|
||||
onComplete: clearScrollLock
|
||||
})
|
||||
return
|
||||
}
|
||||
isScrolling = false
|
||||
clearScrollLock()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stars } = defineProps<{
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="`ComfyUI on GitHub — ${stars} stars`"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-1 lg:flex"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[{ text: stars }]"
|
||||
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
|
||||
size-class="h-5 sm:h-5"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7"
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -14,23 +14,28 @@ const logos = [
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
|
||||
const row1 = logos.slice(0, 6)
|
||||
const mobileRow1 = [...row1, ...row1]
|
||||
const row2 = logos.slice(6)
|
||||
const mobileRow2 = [...row2, ...row2]
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div class="animate-marquee hidden items-center gap-2 md:flex">
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div
|
||||
v-for="(logo, i) in desktopLogos"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,22 +44,38 @@ const mobileRow2 = [...row2, ...row2]
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="animate-marquee flex items-center gap-8">
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in mobileRow1"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="animate-marquee-reverse flex items-center gap-8">
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in mobileRow2"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { ScrollTrigger } from '../../scripts/gsapSetup'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -22,6 +23,10 @@ useHeroAnimation({
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
|
||||
function handleLogoLoad() {
|
||||
ScrollTrigger.refresh(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,7 +42,10 @@ useHeroAnimation({
|
||||
<img
|
||||
src="https://media.comfy.org/website/customers/c-projection.webp"
|
||||
alt="Comfy 3D logo"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
width="1568"
|
||||
height="1763"
|
||||
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
|
||||
@load="handleLogoLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import { t } from '../../i18n/translations'
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
aspectRatio = 16 / 9,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
aspectRatio?: number
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
@@ -24,7 +26,8 @@ const loaded = ref(false)
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
:style="{ aspectRatio }"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
|
||||
@@ -25,7 +25,7 @@ const categories: Category[] = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
|
||||
},
|
||||
{
|
||||
label: t('useCase.advertising', locale),
|
||||
|
||||
@@ -276,29 +276,6 @@ onUnmounted(() => {
|
||||
fill="#211927"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
x="300"
|
||||
y="150"
|
||||
width="250"
|
||||
height="900"
|
||||
fill="url(#localHeroFadeLeft)"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="localHeroFadeLeft"
|
||||
x1="550"
|
||||
y1="600"
|
||||
x2="300"
|
||||
y2="600"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@ interface Demo {
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
/**
|
||||
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
|
||||
* landscape screencast). Sizes the embed container to match so rounded
|
||||
* corners hug the content instead of empty letterbox space. Source from
|
||||
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
|
||||
* invert it). Defaults to 16/9 if omitted.
|
||||
*/
|
||||
readonly aspectRatio?: number
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'community-workflows',
|
||||
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.community-workflows.title',
|
||||
description: 'demos.community-workflows.description',
|
||||
transcript: 'demos.community-workflows.transcript',
|
||||
ogImage: '/images/demos/community-workflows-og.png',
|
||||
thumbnail: '/images/demos/community-workflows-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'community', 'workflow', 'hub'],
|
||||
publishedDate: '2026-05-04',
|
||||
modifiedDate: '2026-05-04',
|
||||
aspectRatio: 1.931
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
5
apps/website/src/data/github-stars.snapshot.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-11T21:41:14.168Z",
|
||||
"repository": "Comfy-Org/ComfyUI",
|
||||
"stargazersCount": 112467
|
||||
}
|
||||
23
apps/website/src/data/githubStars.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface GitHubStarsSnapshot {
|
||||
fetchedAt: string
|
||||
repository: 'Comfy-Org/ComfyUI'
|
||||
stargazersCount: number
|
||||
}
|
||||
|
||||
export function isGitHubStarsSnapshot(
|
||||
value: unknown
|
||||
): value is GitHubStarsSnapshot {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const candidate = value as {
|
||||
fetchedAt?: unknown
|
||||
repository?: unknown
|
||||
stargazersCount?: unknown
|
||||
}
|
||||
return (
|
||||
typeof candidate.fetchedAt === 'string' &&
|
||||
candidate.repository === 'Comfy-Org/ComfyUI' &&
|
||||
typeof candidate.stargazersCount === 'number' &&
|
||||
Number.isSafeInteger(candidate.stargazersCount) &&
|
||||
candidate.stargazersCount >= 0
|
||||
)
|
||||
}
|
||||
@@ -3570,6 +3570,20 @@ const translations = {
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.community-workflows.title': {
|
||||
en: 'Explore and Use a Community Workflow from the Hub',
|
||||
'zh-CN': '探索并使用社区工作流'
|
||||
},
|
||||
'demos.community-workflows.description': {
|
||||
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
|
||||
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
|
||||
},
|
||||
'demos.community-workflows.transcript': {
|
||||
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
|
||||
@@ -5,7 +5,8 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import SiteNav from '../components/common/SiteNav.vue'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
import { fetchGitHubStarsForBuild, formatStarCount } from '../utils/github'
|
||||
import { reportGitHubStarsOutcome } from '../utils/github.ci'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -30,8 +31,17 @@ const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
const githubStarsOutcome = await fetchGitHubStarsForBuild()
|
||||
reportGitHubStarsOutcome(githubStarsOutcome)
|
||||
|
||||
if (githubStarsOutcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`GitHub stars fetch failed and no snapshot is available. Reason: ${githubStarsOutcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website github-stars:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
const githubStars = formatStarCount(githubStarsOutcome.snapshot.stargazersCount)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -121,6 +121,7 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
client:load
|
||||
/>
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
@@ -101,13 +101,13 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
transform: translateX(-50%);
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -115,11 +115,15 @@
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-marquee-reverse {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple-effect {
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './ashby'
|
||||
|
||||
let hasReported = false
|
||||
import {
|
||||
createBuildDataReporter,
|
||||
describeSnapshotAge,
|
||||
escapeAnnotation
|
||||
} from './buildDataReporter'
|
||||
|
||||
export function resetAshbyReporterForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
const ashbyReporter = createBuildDataReporter({
|
||||
summaryHeading: '## 💼 Careers (Ashby)\n',
|
||||
buildAnnotations,
|
||||
buildSummaryRows
|
||||
})
|
||||
|
||||
export function reportAshbyOutcome(outcome: FetchOutcome): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(summaryPath, buildStepSummary(outcome))
|
||||
} catch {
|
||||
// Writing the summary is best-effort; do not fail the build if the
|
||||
// runner's summary file is unavailable (e.g. local dev).
|
||||
}
|
||||
}
|
||||
}
|
||||
export const resetAshbyReporterForTests = ashbyReporter.resetForTests
|
||||
export const reportAshbyOutcome = ashbyReporter.report
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') {
|
||||
if (outcome.droppedCount === 0) return []
|
||||
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
|
||||
const drops = outcome.droppedRoles
|
||||
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
|
||||
.map(
|
||||
(d) =>
|
||||
` - ${escapeAnnotation(
|
||||
d.title ? `"${d.title}"` : '(untitled)'
|
||||
)}: ${escapeAnnotation(d.reason)}`
|
||||
)
|
||||
.join('%0A')
|
||||
return [
|
||||
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
|
||||
@@ -63,12 +55,7 @@ function staleAnnotation(reason: string): string {
|
||||
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
const header = '## 💼 Careers (Ashby)\n'
|
||||
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
@@ -95,19 +82,5 @@ function buildStepSummary(outcome: FetchOutcome): string {
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${header}${table}\n`
|
||||
}
|
||||
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -9,25 +9,24 @@ import {
|
||||
AshbyJobBoardResponseSchema,
|
||||
AshbyJobPostingSchema
|
||||
} from './ashby.schema'
|
||||
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
|
||||
import { createBuildDataSource } from './buildDataSource'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedRole {
|
||||
interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
snapshot: RolesSnapshot
|
||||
droppedCount: number
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
interface FreshRolesData {
|
||||
droppedCount: number
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
|
||||
export type FetchOutcome = BuildDataOutcome<RolesSnapshot, FreshRolesData>
|
||||
|
||||
interface FetchRolesOptions {
|
||||
apiKey?: string
|
||||
@@ -40,56 +39,50 @@ interface FetchRolesOptions {
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
let inflight: Promise<FetchOutcome> | undefined
|
||||
const ashbySource = createBuildDataSource<
|
||||
FetchRolesOptions,
|
||||
RolesSnapshot,
|
||||
FreshRolesData
|
||||
>({
|
||||
name: 'Ashby roles',
|
||||
fetchFresh: fetchFreshRoles,
|
||||
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
|
||||
getCacheKey: getAshbyCacheKey
|
||||
})
|
||||
|
||||
export function resetAshbyFetcherForTests(): void {
|
||||
inflight = undefined
|
||||
}
|
||||
export const resetAshbyFetcherForTests = ashbySource.resetForTests
|
||||
export const fetchRolesForBuild = ashbySource.fetchForBuild
|
||||
|
||||
export function fetchRolesForBuild(
|
||||
options: FetchRolesOptions = {}
|
||||
): Promise<FetchOutcome> {
|
||||
inflight ??= doFetchRolesForBuild(options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function doFetchRolesForBuild(
|
||||
async function fetchFreshRoles(
|
||||
options: FetchRolesOptions
|
||||
): Promise<FetchOutcome> {
|
||||
): Promise<BuildDataFetchResult<RolesSnapshot, FreshRolesData>> {
|
||||
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
|
||||
const boardName =
|
||||
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
|
||||
if (!apiKey || !boardName) {
|
||||
return fallback(
|
||||
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
options.snapshotUrl
|
||||
)
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME'
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tryFetchAndParse(apiKey, boardName, options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
kind: 'ok',
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: result.departments
|
||||
},
|
||||
droppedCount: result.droppedRoles.length,
|
||||
droppedRoles: result.droppedRoles
|
||||
data: {
|
||||
droppedCount: result.droppedRoles.length,
|
||||
droppedRoles: result.droppedRoles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
return result
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
@@ -297,3 +290,15 @@ function isRolesSnapshot(value: unknown): value is RolesSnapshot {
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getAshbyCacheKey(options: FetchRolesOptions): string {
|
||||
return JSON.stringify({
|
||||
apiKey: options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY ?? '',
|
||||
boardName:
|
||||
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME ?? '',
|
||||
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
retryDelaysMs: options.retryDelaysMs ?? RETRY_DELAYS_MS,
|
||||
snapshotUrl: options.snapshotUrl?.href ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
78
apps/website/src/utils/buildDataReporter.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { BuildDataOutcome } from './buildDataSource'
|
||||
|
||||
type SummaryRows = Array<[string, string]>
|
||||
|
||||
interface BuildDataReporterConfig<
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
> {
|
||||
summaryHeading: string
|
||||
buildAnnotations: (
|
||||
outcome: BuildDataOutcome<TSnapshot, TFreshData>
|
||||
) => string[]
|
||||
buildSummaryRows: (
|
||||
outcome: BuildDataOutcome<TSnapshot, TFreshData>
|
||||
) => SummaryRows
|
||||
}
|
||||
|
||||
export function createBuildDataReporter<
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
>(config: BuildDataReporterConfig<TSnapshot, TFreshData>) {
|
||||
let hasReported = false
|
||||
|
||||
function resetForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
|
||||
function report(outcome: BuildDataOutcome<TSnapshot, TFreshData>): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = config.buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(
|
||||
summaryPath,
|
||||
buildStepSummary(
|
||||
config.summaryHeading,
|
||||
config.buildSummaryRows(outcome)
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// Best-effort only; a missing local summary file must not fail a build.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { report, resetForTests }
|
||||
}
|
||||
|
||||
export function escapeAnnotation(value: string): string {
|
||||
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
|
||||
}
|
||||
|
||||
export function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
|
||||
function buildStepSummary(heading: string, rows: SummaryRows): string {
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([key, value]) => `| **${key}** | ${value} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${heading}${table}\n`
|
||||
}
|
||||
82
apps/website/src/utils/buildDataSource.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createBuildDataSource } from './buildDataSource'
|
||||
|
||||
interface TestOptions {
|
||||
cacheKey?: string
|
||||
snapshot?: string
|
||||
result?: 'fresh' | 'stale' | 'failed'
|
||||
}
|
||||
|
||||
describe('createBuildDataSource', () => {
|
||||
it('returns fresh data without reading the snapshot', async () => {
|
||||
const readSnapshot = vi.fn(async () => 'snapshot')
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh: async () => ({
|
||||
kind: 'ok',
|
||||
snapshot: 'fresh',
|
||||
data: {}
|
||||
}),
|
||||
readSnapshot
|
||||
})
|
||||
|
||||
await expect(source.fetchForBuild()).resolves.toEqual({
|
||||
status: 'fresh',
|
||||
snapshot: 'fresh'
|
||||
})
|
||||
expect(readSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a snapshot when fresh fetch fails', async () => {
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
|
||||
readSnapshot: async (options) => options.snapshot ?? null
|
||||
})
|
||||
|
||||
await expect(
|
||||
source.fetchForBuild({ snapshot: 'last-known-good' })
|
||||
).resolves.toEqual({
|
||||
status: 'stale',
|
||||
snapshot: 'last-known-good',
|
||||
reason: 'HTTP 500'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns failed when fresh fetch and snapshot are unavailable', async () => {
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
|
||||
readSnapshot: async () => null
|
||||
})
|
||||
|
||||
await expect(source.fetchForBuild()).resolves.toEqual({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500'
|
||||
})
|
||||
})
|
||||
|
||||
it('memoizes matching cache keys and rejects mismatched cache keys', async () => {
|
||||
const fetchFresh = vi.fn(async () => ({
|
||||
kind: 'ok' as const,
|
||||
snapshot: 'fresh',
|
||||
data: {}
|
||||
}))
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh,
|
||||
readSnapshot: async () => null,
|
||||
getCacheKey: (options) => options.cacheKey ?? 'default'
|
||||
})
|
||||
|
||||
const first = await source.fetchForBuild({ cacheKey: 'a' })
|
||||
const second = await source.fetchForBuild({ cacheKey: 'a' })
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect(fetchFresh).toHaveBeenCalledTimes(1)
|
||||
expect(() => source.fetchForBuild({ cacheKey: 'b' })).toThrow(
|
||||
/called twice with different options/
|
||||
)
|
||||
})
|
||||
})
|
||||
83
apps/website/src/utils/buildDataSource.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export type BuildDataOutcome<TSnapshot, TFreshData extends object = object> =
|
||||
| ({ status: 'fresh'; snapshot: TSnapshot } & TFreshData)
|
||||
| { status: 'stale'; snapshot: TSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
export type BuildDataFetchResult<
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
> =
|
||||
| { kind: 'ok'; snapshot: TSnapshot; data: TFreshData }
|
||||
| { kind: 'err'; reason: string }
|
||||
|
||||
interface BuildDataSourceConfig<
|
||||
TOptions extends object,
|
||||
TSnapshot,
|
||||
TFreshData extends object
|
||||
> {
|
||||
name: string
|
||||
fetchFresh: (
|
||||
options: TOptions
|
||||
) => Promise<BuildDataFetchResult<TSnapshot, TFreshData>>
|
||||
readSnapshot: (options: TOptions) => Promise<TSnapshot | null>
|
||||
getCacheKey?: (options: TOptions) => string
|
||||
}
|
||||
|
||||
export function createBuildDataSource<
|
||||
TOptions extends object,
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
>(config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>) {
|
||||
let inflight: Promise<BuildDataOutcome<TSnapshot, TFreshData>> | undefined
|
||||
let inflightCacheKey: string | undefined
|
||||
|
||||
function resetForTests(): void {
|
||||
inflight = undefined
|
||||
inflightCacheKey = undefined
|
||||
}
|
||||
|
||||
function fetchForBuild(
|
||||
options = {} as TOptions
|
||||
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
|
||||
const cacheKey = config.getCacheKey?.(options) ?? 'default'
|
||||
if (inflight) {
|
||||
if (inflightCacheKey !== cacheKey) {
|
||||
throw new Error(
|
||||
`${config.name} fetcher called twice with different options; reset between distinct configurations`
|
||||
)
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
|
||||
inflightCacheKey = cacheKey
|
||||
inflight = doFetchForBuild(config, options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
return { fetchForBuild, resetForTests }
|
||||
}
|
||||
|
||||
async function doFetchForBuild<
|
||||
TOptions extends object,
|
||||
TSnapshot,
|
||||
TFreshData extends object
|
||||
>(
|
||||
config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>,
|
||||
options: TOptions
|
||||
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
|
||||
const result = await config.fetchFresh(options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: result.snapshot,
|
||||
...result.data
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await config.readSnapshot(options)
|
||||
if (snapshot) {
|
||||
return { status: 'stale', snapshot, reason: result.reason }
|
||||
}
|
||||
|
||||
return { status: 'failed', reason: result.reason }
|
||||
}
|
||||
83
apps/website/src/utils/github.ci.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './github'
|
||||
|
||||
import {
|
||||
reportGitHubStarsOutcome,
|
||||
resetGitHubStarsReporterForTests
|
||||
} from './github.ci'
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
repository: 'Comfy-Org/ComfyUI' as const,
|
||||
stargazersCount: 112464
|
||||
}
|
||||
}
|
||||
|
||||
function freshOutcome(): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
describe('reportGitHubStarsOutcome', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
let summaryDir: string
|
||||
let summaryPath: string
|
||||
const originalSummary = process.env.GITHUB_STEP_SUMMARY
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitHubStarsReporterForTests()
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
summaryDir = mkdtempSync(join(tmpdir(), 'github-stars-summary-'))
|
||||
summaryPath = join(summaryDir, 'summary.md')
|
||||
writeFileSync(summaryPath, '')
|
||||
process.env.GITHUB_STEP_SUMMARY = summaryPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore()
|
||||
rmSync(summaryDir, { recursive: true, force: true })
|
||||
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
|
||||
else process.env.GITHUB_STEP_SUMMARY = originalSummary
|
||||
})
|
||||
|
||||
it('emits no annotation on a fresh outcome', () => {
|
||||
reportGitHubStarsOutcome(freshOutcome())
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
|
||||
})
|
||||
|
||||
it('emits exactly one stale warning across repeated calls', () => {
|
||||
const outcome: FetchOutcome = {
|
||||
status: 'stale',
|
||||
reason: 'HTTP 403 rate limited',
|
||||
snapshot: snapshot()
|
||||
}
|
||||
|
||||
reportGitHubStarsOutcome(outcome)
|
||||
reportGitHubStarsOutcome(outcome)
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=GitHub stars unavailable')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Stale')
|
||||
})
|
||||
|
||||
it('emits an error for failed outcomes', () => {
|
||||
reportGitHubStarsOutcome({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500 Server Error'
|
||||
})
|
||||
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=GitHub stars fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
})
|
||||
})
|
||||
56
apps/website/src/utils/github.ci.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { FetchOutcome } from './github'
|
||||
|
||||
import {
|
||||
createBuildDataReporter,
|
||||
describeSnapshotAge,
|
||||
escapeAnnotation
|
||||
} from './buildDataReporter'
|
||||
|
||||
const githubStarsReporter = createBuildDataReporter({
|
||||
summaryHeading: '## GitHub stars\n',
|
||||
buildAnnotations,
|
||||
buildSummaryRows
|
||||
})
|
||||
|
||||
export const resetGitHubStarsReporterForTests =
|
||||
githubStarsReporter.resetForTests
|
||||
export const reportGitHubStarsOutcome = githubStarsReporter.report
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') return []
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [
|
||||
`::warning title=GitHub stars unavailable::${escapeAnnotation(
|
||||
outcome.reason
|
||||
)}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check GitHub API availability/rate limits.%0A 2. Re-run the build once GitHub is healthy.%0A 3. To refresh the fallback value, run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\` and commit apps/website/src/data/github-stars.snapshot.json.`
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
`::error title=GitHub stars fetch failed and no snapshot is available::Cannot render website navigation stars.%0A%0AReason: ${escapeAnnotation(
|
||||
outcome.reason
|
||||
)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\`.%0A 2. Commit apps/website/src/data/github-stars.snapshot.json.%0A 3. Push and re-run CI.`
|
||||
]
|
||||
}
|
||||
|
||||
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
rows.push(['Status', 'Fresh (fetched from GitHub)'])
|
||||
rows.push(['Repository', outcome.snapshot.repository])
|
||||
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
|
||||
} else if (outcome.status === 'stale') {
|
||||
rows.push(['Status', 'Stale (using snapshot - GitHub fetch failed)'])
|
||||
rows.push(['Repository', outcome.snapshot.repository])
|
||||
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
|
||||
} else {
|
||||
rows.push(['Status', 'Failed (no snapshot available)'])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -1,10 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { fetchGitHubStars, formatStarCount } from './github'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GitHubStarsSnapshot } from '../data/githubStars'
|
||||
|
||||
import {
|
||||
fetchGitHubStars,
|
||||
fetchGitHubStarsForBuild,
|
||||
formatStarCount,
|
||||
resetGitHubStarsFetcherForTests
|
||||
} from './github'
|
||||
|
||||
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
||||
const base: ResponseInit = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
return new Response(JSON.stringify(body), { ...base, ...init })
|
||||
}
|
||||
|
||||
function makeSnapshot(stargazersCount = 111_605): GitHubStarsSnapshot {
|
||||
return {
|
||||
fetchedAt: '2026-05-06T00:00:00.000Z',
|
||||
repository: 'Comfy-Org/ComfyUI',
|
||||
stargazersCount
|
||||
}
|
||||
}
|
||||
|
||||
function withSnapshotDir(snapshot: GitHubStarsSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'github-stars-test-'))
|
||||
const file = join(dir, 'github-stars.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
}
|
||||
|
||||
describe('fetchGitHubStars', () => {
|
||||
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitHubStarsFetcherForTests()
|
||||
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (savedOverride === undefined)
|
||||
@@ -16,16 +56,86 @@ describe('fetchGitHubStars', () => {
|
||||
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
|
||||
await expect(fetchGitHubStars()).resolves.toBe(110000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails fast when the build-time override is malformed', async () => {
|
||||
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
|
||||
await expect(fetchGitHubStars()).rejects.toThrow(
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns fresh stars when the API succeeds', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({ stargazers_count: 112464 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl })
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.repository).toBe('Comfy-Org/ComfyUI')
|
||||
expect(outcome.snapshot.stargazersCount).toBe(112464)
|
||||
})
|
||||
|
||||
it('memoizes build-time star fetches within a single process', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({ stargazers_count: 110000 })
|
||||
)
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
fetchGitHubStarsForBuild({ fetchImpl }),
|
||||
fetchGitHubStarsForBuild({ fetchImpl })
|
||||
])
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to the committed snapshot when GitHub fetch fails', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({}, { status: 403 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
|
||||
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 403/)
|
||||
expect(outcome.snapshot.stargazersCount).toBe(111605)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when both GitHub fetch and snapshot are unavailable', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({}, { status: 403 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
|
||||
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects invalid numeric star counts before caching them', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({ stargazers_count: -1 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
|
||||
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^response schema validation failed/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,22 +1,129 @@
|
||||
export async function fetchGitHubStars(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<number | null> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) return override
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { GitHubStarsSnapshot } from '../data/githubStars'
|
||||
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
|
||||
|
||||
import bundledSnapshot from '../data/github-stars.snapshot.json' with { type: 'json' }
|
||||
import { isGitHubStarsSnapshot } from '../data/githubStars'
|
||||
import { createBuildDataSource } from './buildDataSource'
|
||||
|
||||
const GITHUB_REPOSITORY = 'Comfy-Org/ComfyUI'
|
||||
const GITHUB_REPO_API_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}`
|
||||
|
||||
export type FetchOutcome = BuildDataOutcome<GitHubStarsSnapshot>
|
||||
|
||||
interface FetchGitHubStarsOptions {
|
||||
fetchImpl?: typeof fetch
|
||||
snapshotUrl?: URL
|
||||
}
|
||||
|
||||
const githubStarsSource = createBuildDataSource<
|
||||
FetchGitHubStarsOptions,
|
||||
GitHubStarsSnapshot
|
||||
>({
|
||||
name: 'GitHub stars',
|
||||
fetchFresh: fetchFreshGitHubStars,
|
||||
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
|
||||
getCacheKey: getGitHubStarsCacheKey
|
||||
})
|
||||
|
||||
export const resetGitHubStarsFetcherForTests = githubStarsSource.resetForTests
|
||||
export const fetchGitHubStarsForBuild = githubStarsSource.fetchForBuild
|
||||
|
||||
export async function fetchGitHubStars(
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<number | null> {
|
||||
const result = await fetchFreshGitHubStars({ fetchImpl })
|
||||
if (result.kind === 'err') return null
|
||||
return result.snapshot.stargazersCount
|
||||
}
|
||||
|
||||
async function fetchFreshGitHubStars(
|
||||
options: FetchGitHubStarsOptions
|
||||
): Promise<BuildDataFetchResult<GitHubStarsSnapshot>> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) {
|
||||
return {
|
||||
kind: 'ok',
|
||||
snapshot: makeSnapshot(override),
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await callOnce(options.fetchImpl ?? fetch)
|
||||
if (response.kind === 'err') return response
|
||||
|
||||
const count = readStargazerCount(response.body)
|
||||
if (count === null) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason:
|
||||
'response schema validation failed: stargazers_count must be a non-negative safe integer'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'ok',
|
||||
snapshot: makeSnapshot(count),
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string }
|
||||
|
||||
async function callOnce(fetchImpl: typeof fetch): Promise<CallResponse> {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
const res = await fetchImpl(GITHUB_REPO_API_URL, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.stargazers_count ?? null
|
||||
if (res.ok) return { kind: 'ok', body: await res.json() }
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason }
|
||||
}
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<GitHubStarsSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isGitHubStarsSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isGitHubStarsSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function makeSnapshot(stargazersCount: number): GitHubStarsSnapshot {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
repository: GITHUB_REPOSITORY,
|
||||
stargazersCount
|
||||
}
|
||||
}
|
||||
|
||||
function readStargazerCount(data: unknown): number | null {
|
||||
if (data === null || typeof data !== 'object') return null
|
||||
const count = (data as { stargazers_count?: unknown }).stargazers_count
|
||||
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
|
||||
? count
|
||||
: null
|
||||
}
|
||||
|
||||
export function formatStarCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
const m = count / 1_000_000
|
||||
@@ -42,3 +149,10 @@ function readGitHubStarsOverride(): number | undefined {
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
function getGitHubStarsCacheKey(options: FetchGitHubStarsOptions): string {
|
||||
return JSON.stringify({
|
||||
override: process.env.WEBSITE_GITHUB_STARS_OVERRIDE ?? '',
|
||||
snapshotUrl: options.snapshotUrl?.href ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -190,6 +190,9 @@ export class ComfyPage {
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
|
||||
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
|
||||
public isVueNodes = false
|
||||
|
||||
/** Test user ID for the current context */
|
||||
get id() {
|
||||
return this.userIds[comfyPageFixture.info().parallelIndex]
|
||||
@@ -500,6 +503,7 @@ export const comfyPageFixture = base.extend<{
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
const isVueNodes = testInfo.tags.includes('@vue-nodes')
|
||||
comfyPage.isVueNodes = isVueNodes
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
|
||||
@@ -217,13 +217,20 @@ export class VueNodeHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the Enter Subgraph footer button.
|
||||
*/
|
||||
getSubgraphEnterButton(nodeId?: string): Locator {
|
||||
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ContextMenu {
|
||||
|
||||
async clickMenuItemExact(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,7 +82,7 @@ export class Topbar {
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
return this.page.locator('.p-dialog-content input')
|
||||
return this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
saveWorkflow(workflowName: string): Promise<void> {
|
||||
@@ -116,9 +116,9 @@ export class Topbar {
|
||||
|
||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||
// If so, return early to let the test handle the confirmation
|
||||
const confirmationDialog = this.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const confirmationDialog = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
if (await confirmationDialog.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
12
browser_tests/fixtures/components/WidgetSelectDropdown.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
export class WidgetSelectDropdownFixture {
|
||||
public readonly selection: Locator
|
||||
|
||||
constructor(public readonly root: Locator) {
|
||||
this.selection = root.locator('button span span')
|
||||
}
|
||||
async selectedItem(): Promise<string> {
|
||||
return await this.selection.innerText()
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
|
||||
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly mobile: MobileAppHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
@@ -60,13 +62,16 @@ export class AppModeHelper {
|
||||
public readonly vueNodeSwitchDismissButton: Locator
|
||||
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
|
||||
/** The main content area where outputs are displayed*/
|
||||
public readonly centerPanel: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.mobile = new MobileAppHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
@@ -125,6 +130,7 @@ export class AppModeHelper {
|
||||
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDontShowAgain
|
||||
)
|
||||
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
|
||||
@@ -215,11 +215,12 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -296,15 +297,29 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
|
||||
@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
@@ -13,6 +14,7 @@ export class DragDropHelper {
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
filePath?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
@@ -22,13 +24,14 @@ export class DragDropHelper {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
filePath,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
if (!fileName && !filePath && !url)
|
||||
throw new Error('Must provide fileName, filePath, or url')
|
||||
|
||||
const evaluateParams: {
|
||||
dropPosition: Position
|
||||
@@ -39,12 +42,22 @@ export class DragDropHelper {
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
if (fileName || filePath) {
|
||||
const resolvedPath = filePath ?? assetPath(fileName!)
|
||||
const displayName = fileName ?? basename(resolvedPath)
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = readFileSync(resolvedPath)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileName = displayName
|
||||
evaluateParams.fileType = getMimeType(displayName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
@@ -148,6 +161,13 @@ export class DragDropHelper {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropFilePath(
|
||||
filePath: string,
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ filePath, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
NodeError,
|
||||
NodeProgressState,
|
||||
PromptResponse
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
@@ -230,6 +234,16 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress_state` WS event with per-node execution state. */
|
||||
progressState(jobId: string, nodes: Record<string, NodeProgressState>): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'progress_state',
|
||||
data: { prompt_id: jobId, nodes }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a job by adding it to mock history, sending execution_success,
|
||||
* and triggering a history refresh via a status event.
|
||||
|
||||
33
browser_tests/fixtures/helpers/MobileAppHelper.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class MobileAppHelper {
|
||||
private readonly page: Page
|
||||
readonly contentPanel: Locator
|
||||
readonly navigation: Locator
|
||||
readonly navigationTabs: Locator
|
||||
readonly view: Locator
|
||||
readonly workflows: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.page = comfyPage.page
|
||||
this.view = this.page.getByTestId(TestIds.linear.mobile)
|
||||
this.contentPanel = this.page.getByRole('tabpanel')
|
||||
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
|
||||
this.navigationTabs = this.navigation.getByRole('tab')
|
||||
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
|
||||
}
|
||||
|
||||
async switchWorkflow(workflowName: string) {
|
||||
await this.workflows.click()
|
||||
await this.page.getByRole('menu').getByText(workflowName).click()
|
||||
}
|
||||
async navigateTab(name: 'run' | 'outputs' | 'assets') {
|
||||
await this.navigation.getByRole('tab', { name }).click()
|
||||
}
|
||||
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
|
||||
for (let i = 0; i < count; i++) await locator.tap()
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
|
||||
public readonly promptDialogInput: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
this.promptDialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
private get page() {
|
||||
|
||||
@@ -362,6 +362,9 @@ export class SubgraphHelper {
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
if (this.comfyPage.isVueNodes) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
|
||||
@@ -144,6 +144,14 @@ export const TestIds = {
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
linear: {
|
||||
centerPanel: 'linear-center-panel',
|
||||
mobile: 'linear-mobile',
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
|
||||
@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.mp3')) return 'audio/mpeg'
|
||||
if (name.endsWith('.flac')) return 'audio/flac'
|
||||
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
|
||||
export function metadataFixturePath(fileName: string): string {
|
||||
return `./src/scripts/metadata/__fixtures__/${fileName}`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export class VueNodeFixture {
|
||||
public readonly collapseButton: Locator
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -23,6 +25,8 @@ export class VueNodeFixture {
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -39,6 +43,16 @@ export class VueNodeFixture {
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select this node and delete it via the Delete key, waiting for the node
|
||||
* element to leave the DOM before resolving.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
await this.header.click()
|
||||
await this.header.press('Delete')
|
||||
await this.locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
return (await this.collapseIcon.getAttribute('class')) ?? ''
|
||||
}
|
||||
|
||||
154
browser_tests/tests/appMode.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
const { centerPanel } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(centerPanel, 'Enter app mode').toBeVisible()
|
||||
|
||||
//an app without an image input will load the workflow
|
||||
await test.step('App without an image input loads workflow', async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
|
||||
await expect(centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
//prep a load image
|
||||
await test.step('Add a load image node', async () => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(loadImage).toBeVisible()
|
||||
})
|
||||
|
||||
const imageInput = new WidgetSelectDropdownFixture(
|
||||
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
|
||||
)
|
||||
|
||||
await test.step('Enter app mode with image input', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
|
||||
await expect(centerPanel).toBeVisible()
|
||||
|
||||
await expect(imageInput.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging an image redirects to image input', async () => {
|
||||
const initialImage = await imageInput.selectedItem()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropExternalResource({
|
||||
fileName: 'workflow.webp',
|
||||
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
|
||||
|
||||
await expect(imageInput.selection).not.toHaveText(initialImage)
|
||||
await expect(
|
||||
centerPanel,
|
||||
'A file with workflow should not open a new workflow'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging a url redirects to image input', async () => {
|
||||
const secondImage = await imageInput.selectedItem()
|
||||
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: 'og-image.png',
|
||||
type: 'input'
|
||||
})
|
||||
await expect(imageInput.selection).not.toHaveText(secondImage)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widget Interaction', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
['3', 'seed'],
|
||||
['3', 'sampler_name'],
|
||||
['6', 'text']
|
||||
])
|
||||
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
|
||||
exact: true
|
||||
})
|
||||
const { input, incrementButton, decrementButton } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seed)
|
||||
const initialValue = Number(await input.inputValue())
|
||||
|
||||
await seed.dragTo(incrementButton, { steps: 5 })
|
||||
const intermediateValue = Number(await input.inputValue())
|
||||
expect(intermediateValue).toBeGreaterThan(initialValue)
|
||||
|
||||
await seed.dragTo(decrementButton, { steps: 5 })
|
||||
const endValue = Number(await input.inputValue())
|
||||
expect(endValue).toBeLessThan(intermediateValue)
|
||||
|
||||
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
|
||||
exact: true
|
||||
})
|
||||
await sampler.click()
|
||||
|
||||
await comfyPage.page.getByRole('searchbox').fill('uni')
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(sampler).toHaveText('uni_pc')
|
||||
|
||||
//verify values are consistent with litegraph
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
|
||||
await expect(mobile.view).toBeVisible()
|
||||
await expect(mobile.navigation).toBeVisible()
|
||||
|
||||
await mobile.navigateTab('assets')
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
|
||||
|
||||
const buttons = await mobile.navigationTabs.all()
|
||||
await buttons[0].dragTo(buttons[2], { steps: 5 })
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
|
||||
|
||||
await mobile.navigateTab('run')
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
|
||||
|
||||
const steps = comfyPage.page.getByRole('spinbutton')
|
||||
const initialValue = Number(await steps.inputValue())
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'increment' }),
|
||||
{ count: 5 }
|
||||
)
|
||||
await expect(steps).toHaveValue(String(initialValue + 5))
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'decrement' }),
|
||||
{ count: 3 }
|
||||
)
|
||||
|
||||
await expect(steps).toHaveValue(String(initialValue + 2))
|
||||
})
|
||||
|
||||
test('workflow selection', async ({ comfyPage }) => {
|
||||
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
|
||||
for (const name of widgetNames)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
|
||||
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
|
||||
|
||||
const widgets = comfyPage.appMode.linearWidgets
|
||||
await comfyPage.appMode.mobile.navigateTab('run')
|
||||
for (let i = 0; i < widgetNames.length; i++) {
|
||||
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
|
||||
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
121
browser_tests/tests/appModeBuilder.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('App mode builder selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
})
|
||||
|
||||
test('Can independently select inputs of same name', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
|
||||
await comfyPage.vueNodes.selectNodes(['6', '7'])
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
const prompts = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.locator('.lg-node-widget')
|
||||
const count = await prompts.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(prompts.nth(i)).toBeVisible()
|
||||
await prompts.nth(i).click()
|
||||
await expect(items).toHaveCount(i + 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('Can select outputs', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToOutputs()
|
||||
|
||||
await comfyPage.nodeOps
|
||||
.getNodeRefById('9')
|
||||
.then((ref) => ref.centerOnNode())
|
||||
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
|
||||
await saveImage.click()
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await expect(items).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
|
||||
//Manually set error state on checkpoint loader
|
||||
//Shouldn't be needed on ci, but has spotty reliability
|
||||
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget(
|
||||
'Load Checkpoint',
|
||||
'ckpt_name'
|
||||
)
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
|
||||
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
|
||||
|
||||
await expect(items).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Marks canvas readOnly', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is initially editable'
|
||||
).toHaveCount(1)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Entering builder makes the canvas readonly'
|
||||
).toHaveCount(0)
|
||||
|
||||
await comfyPage.page.keyboard.press('Space')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas remains readonly after pressing space'
|
||||
).toHaveCount(0)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await ksampler.header.dblclick({ force: true })
|
||||
await expect(
|
||||
ksampler.titleEditor.input,
|
||||
'Double clicking node titles will not initiate a rename'
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is no longer readonly after exiting'
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
|
||||
@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.keyboard.press('Control+s')
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
// The Save As dialog should appear
|
||||
const saveDialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(saveDialog).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const overwriteDialog = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
|
||||
62
browser_tests/tests/metadataWorkflowImport.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
type MetadataFixture = {
|
||||
fileName: string
|
||||
parser: string
|
||||
}
|
||||
|
||||
// Each fixture embeds the same single-KSampler workflow (see
|
||||
// scripts/generate-embedded-metadata-test-files.py), exercising a different
|
||||
// parser in src/scripts/metadata/. Dropping the file should import that
|
||||
// workflow.
|
||||
const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
|
||||
{ fileName: 'with_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
for (const { fileName, parser } of FIXTURES) {
|
||||
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the fixture'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -8,6 +8,9 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const DEPRECATED_NODE_TYPE = 'ImageBatch'
|
||||
const API_NODE_TYPE = 'FluxProUltraImageNode'
|
||||
|
||||
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
@@ -141,3 +144,73 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
for (const vueEnabled of [false, true] as const) {
|
||||
const renderer = vueEnabled ? 'vue' : 'classic'
|
||||
const tag = vueEnabled
|
||||
? ['@vue-nodes', '@screenshot', '@node']
|
||||
: ['@screenshot', '@node']
|
||||
|
||||
test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
})
|
||||
|
||||
for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
|
||||
test(`renders deprecated node with mode=${mode}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
|
||||
mode
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`node-lifecycle-${mode}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe(`API pricing badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
await comfyPage.page.evaluate((type) => {
|
||||
const registered = window.LiteGraph!.registered_node_types[type] as {
|
||||
nodeData?: { price_badge?: unknown }
|
||||
}
|
||||
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
|
||||
registered.nodeData.price_badge = {
|
||||
engine: 'jsonata',
|
||||
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
}, API_NODE_TYPE)
|
||||
})
|
||||
|
||||
for (const enabled of [true, false] as const) {
|
||||
test(`renders api node with showApiPricing=${enabled}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.ShowApiPricing',
|
||||
enabled
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
test('Controls stack label above widget in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
const brushButton = painterWidget.getByText('Brush', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
'tool label should be visible in wide layout'
|
||||
).toBeVisible()
|
||||
|
||||
const wideLabelBox = await toolLabel.boundingBox()
|
||||
const wideBrushBox = await brushButton.boundingBox()
|
||||
expect(
|
||||
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
|
||||
'label should sit to the left of the brush button in wide layout'
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
'tool label should remain visible in compact layout'
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const labelBox = await toolLabel.boundingBox()
|
||||
const brushBox = await brushButton.boundingBox()
|
||||
if (!labelBox || !brushBox) return false
|
||||
return labelBox.y + labelBox.height <= brushBox.y
|
||||
},
|
||||
{
|
||||
message: 'label should stack above the brush button in compact layout'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
|
||||
@@ -558,5 +558,52 @@ test.describe(
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.fail(
|
||||
'Promoted text widget is removed when source node is deleted inside the subgraph',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.first()
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const subgraphNodeId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('text')
|
||||
await expect(
|
||||
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await interiorClip.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const subgraphNodeAfter =
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
await expect(
|
||||
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
@@ -188,4 +189,79 @@ test.describe('Workflow tabs', () => {
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.describe('Closing a modified workflow tab (FE-419)', () => {
|
||||
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
|
||||
await page.evaluate(() => {
|
||||
const graph = window.app?.graph
|
||||
const node = window.LiteGraph?.createNode('Note')
|
||||
if (graph && node) graph.add(node)
|
||||
})
|
||||
await expect(
|
||||
activeTab.getByTestId('workflow-dirty-indicator')
|
||||
).toHaveCount(1)
|
||||
}
|
||||
|
||||
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Close anyway' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('clicking "Close anyway" closes the tab without saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Close anyway' })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('dismissing the dialog keeps the modified tab open', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,9 +21,8 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
|
||||
const nodeId = String(loadImageNode.id)
|
||||
const imagePreview = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.image-preview')
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
|
||||
@@ -44,6 +43,25 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test('hides mask and download buttons when image is missing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/load_image_widget_missing_file'
|
||||
)
|
||||
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.getByTestId('error-loading-image')).toBeVisible()
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
|
||||
await expect(imagePreview.getByLabel('Edit or mask image')).toHaveCount(0)
|
||||
await expect(imagePreview.getByLabel('Download image')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows image context menu options', async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height * 0.75
|
||||
}
|
||||
await comfyPage.canvasOps.dragAndDrop(start, {
|
||||
x: start.x + 120,
|
||||
y: start.y + 80
|
||||
})
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const beforePos = await node.boundingBox()
|
||||
if (!beforePos) throw new Error('Node has no bounding box')
|
||||
|
||||
await dragFromTabButton(comfyPage, showButton)
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const afterPos = await node.boundingBox()
|
||||
if (!afterPos) throw new Error('Node missing after drag')
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const beforePos = await subgraphNode.getPosition()
|
||||
|
||||
await dragFromTabButton(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getSubgraphEnterButton('2')
|
||||
)
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
const afterPos = await subgraphNode.getPosition()
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
211
browser_tests/tests/wsReconnectStaleJob.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type {
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
const EXECUTING_CLASS = /outline-node-stroke-executing/
|
||||
|
||||
const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/
|
||||
const HISTORY_ROUTE = /\/api\/jobs\?[^/]*status=completed/
|
||||
|
||||
function jobsResponse(jobs: RawJobListItem[]): JobsListResponse {
|
||||
return {
|
||||
jobs,
|
||||
pagination: { offset: 0, limit: 200, total: jobs.length, has_more: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function mockJobsRoute(
|
||||
comfyPage: ComfyPage,
|
||||
pattern: RegExp,
|
||||
body: string,
|
||||
status: number = 200
|
||||
): Promise<() => number> {
|
||||
let count = 0
|
||||
await comfyPage.page.route(pattern, async (route) => {
|
||||
count += 1
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body
|
||||
})
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
const emptyJobsBody = JSON.stringify(jobsResponse([]))
|
||||
|
||||
type Scenario = {
|
||||
name: string
|
||||
/** Built per-test so it can incorporate the runtime-assigned jobId. */
|
||||
queueBody: (jobId: string) => string
|
||||
/** Whether the active job state should still be reflected after reconnect. */
|
||||
expectsActiveAfter: boolean
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: 'clears stale active job when queue is empty after reconnect',
|
||||
queueBody: () => emptyJobsBody,
|
||||
expectsActiveAfter: false
|
||||
},
|
||||
{
|
||||
name: 'preserves active job when the job is still in the queue',
|
||||
queueBody: (jobId) =>
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
),
|
||||
expectsActiveAfter: true
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Stub the queue/history endpoints per `scenario`, close the WS, and wait
|
||||
* for the auto-reconnect to issue a fresh queue fetch.
|
||||
*/
|
||||
async function triggerReconnect(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute,
|
||||
scenario: Scenario,
|
||||
jobId: string
|
||||
): Promise<void> {
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
const queueFetches = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
scenario.queueBody(jobId)
|
||||
)
|
||||
const fetchesBeforeClose = queueFetches()
|
||||
await ws.close()
|
||||
await expect.poll(queueFetches).toBeGreaterThan(fetchesBeforeClose)
|
||||
}
|
||||
|
||||
test.describe('WebSocket reconnect with stale job', { tag: '@ui' }, () => {
|
||||
test.describe('app mode skeleton', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
// Skeleton visibility is the deterministic sync point: it appears
|
||||
// once both `storeJob` (HTTP) and `executionStart` (WS) have been
|
||||
// processed, regardless of arrival order.
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
} else {
|
||||
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('preserves active job when the queue endpoint fails on reconnect', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
|
||||
// Prime queueStore.runningTasks with the active job — a WS status
|
||||
// event drives GraphView.onStatus -> queueStore.update().
|
||||
const primer = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
)
|
||||
)
|
||||
exec.status(1)
|
||||
await expect.poll(primer).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Swap to a failing handler so the reconnect-driven fetch 500s.
|
||||
// The fix should preserve runningTasks from the priming call rather
|
||||
// than overwriting it with empty/error state.
|
||||
await comfyPage.page.unroute(QUEUE_ROUTE)
|
||||
const failed = await mockJobsRoute(comfyPage, QUEUE_ROUTE, '{}', 500)
|
||||
|
||||
const before = failed()
|
||||
await ws.close()
|
||||
await expect.poll(failed).toBeGreaterThan(before)
|
||||
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('vue node executing class', { tag: '@vue-nodes' }, () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
// The executing outline lives on the outer `[data-node-id]`
|
||||
// container, not the inner wrapper.
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(KSAMPLER_NODE)
|
||||
await expect(ksamplerNode).toBeVisible()
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
exec.progressState(jobId, {
|
||||
[KSAMPLER_NODE]: {
|
||||
value: 0,
|
||||
max: 1,
|
||||
state: 'running',
|
||||
node_id: KSAMPLER_NODE,
|
||||
display_node_id: KSAMPLER_NODE,
|
||||
prompt_id: jobId
|
||||
}
|
||||
})
|
||||
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
} else {
|
||||
await expect(ksamplerNode).not.toHaveClass(EXECUTING_CLASS)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
|
||||
328
docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 9. Subgraph promoted widgets use linked inputs
|
||||
|
||||
Date: 2026-05-05
|
||||
|
||||
Appendices:
|
||||
|
||||
- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
|
||||
- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
|
||||
- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Subgraph widget promotion historically had two overlapping representations:
|
||||
|
||||
1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
|
||||
2. linked subgraph inputs, where an interior widget-bearing input is exposed
|
||||
through the subgraph boundary.
|
||||
|
||||
This created ambiguous ownership. Runtime value reads could collapse to an
|
||||
interior source widget, while host `widgets_values` could also carry an
|
||||
exterior value. Multiple host instances of the same subgraph could therefore
|
||||
stomp one another, and serialization could mutate interior widgets as a
|
||||
persistence carrier for exterior values.
|
||||
|
||||
The ECS widget migration makes that ambiguity more expensive: widgets are
|
||||
becoming entities with component state keyed by stable entity identity, and
|
||||
subgraphs are modeled as graph boundary structure rather than a separate
|
||||
promotion-specific entity kind.
|
||||
|
||||
## Decision
|
||||
|
||||
Promoted widgets are represented only as standard linked `SubgraphInput`
|
||||
widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
|
||||
input on a host `SubgraphNode`. The interior source widget supplies schema,
|
||||
type, options, tooltip, and default metadata, but it is not the owner of the
|
||||
host value.
|
||||
|
||||
Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
|
||||
promoted widget. It is a separate preview-exposure system because it has no
|
||||
host-owned widget value, does not feed prompt serialization, and often points at
|
||||
virtual `serialize: false` pseudo-widgets that may not exist on the source node.
|
||||
|
||||
`properties.proxyWidgets` becomes a legacy load-time input only. Successful
|
||||
repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
|
||||
those entries. The standard serialized representation is the existing subgraph
|
||||
interface/input form plus host-node `widgets_values`.
|
||||
|
||||
Display-only preview exposures use their own host-node-scoped serialized entry,
|
||||
`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
|
||||
of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
|
||||
language, not widget language:
|
||||
|
||||
```ts
|
||||
type PreviewExposure = {
|
||||
name: string
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
```
|
||||
|
||||
Host-node scope preserves current behavior where different instances of the
|
||||
same subgraph can choose different exposed previews.
|
||||
|
||||
The entry intentionally stores only host preview identity and source locator
|
||||
identity. `name` is the host-scoped stable identity for this preview exposure,
|
||||
analogous to `SubgraphInput.name`; it is not a display label. It is generated
|
||||
with existing collision behavior, such as `nextUniqueName(...)`, when an
|
||||
exposure is created. Media type, display labels, titles, image/video/audio URLs,
|
||||
and other runtime preview details are derived from the current graph and output
|
||||
state. Array order is the canonical display order. Preview exposures do not get
|
||||
a separate persisted `label` in this slice; if a future rename UX needs one, it
|
||||
should follow the same rule as subgraph inputs: `name` is identity and `label`
|
||||
is display-only.
|
||||
|
||||
Preview exposures are persisted user choices after creation. Packing nodes into
|
||||
a subgraph may auto-add recommended preview exposures for supported output
|
||||
nodes, and users may explicitly add or remove additional preview exposures
|
||||
afterward. Normal load/save does not re-derive previews from node type alone,
|
||||
because that would make old workflows change when support for new preview node
|
||||
types is added. Unresolved preview exposures remain persisted and inert;
|
||||
automatic cleanup does not prune them. They are removed only by explicit user
|
||||
action or by destruction/unpacking of the owning host.
|
||||
|
||||
Preview exposures compose through nested subgraph hosts by chaining immediate
|
||||
boundaries. If an outer subgraph wants to show a preview exposed by an inner
|
||||
subgraph host, the outer `previewExposures` entry points at the immediate inner
|
||||
`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
|
||||
identity, not the deepest interior preview name. Runtime preview resolution may
|
||||
then follow the inner host's own preview exposures to find media. Canonical JSON
|
||||
does not persist flattened deep paths, because deep paths would couple host UI
|
||||
state to private nested graph internals.
|
||||
|
||||
## Identity and value ownership
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- Host-scoped identity means the host `SubgraphNode` instance within its
|
||||
containing `graphScope`; the interior source node is not the state or
|
||||
persistence owner.
|
||||
- `SubgraphInput.name` is the stable internal identity.
|
||||
- `SubgraphInput.label` / `localized_name` are display-only.
|
||||
- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
|
||||
persisted widget value key.
|
||||
- Source node/widget identity remains metadata for diagnostics, missing-model
|
||||
lookup, schema projection, and migration only.
|
||||
- The host/exterior value wins over the interior/source value during repair,
|
||||
persistence, and prompt serialization.
|
||||
|
||||
This follows the existing widget/slot convention: `name` is identity, `label`
|
||||
is display.
|
||||
|
||||
Promoted-widget value state is a host-scoped sparse overlay over source-widget
|
||||
metadata and defaults. The source widget remains the schema/default provider;
|
||||
host value state is materialized only when the exterior value differs from the
|
||||
effective source default or when restored from persisted host state. Canonical
|
||||
save/load must not eagerly mirror source defaults or use interior widgets as
|
||||
persistence carriers.
|
||||
|
||||
## Forward migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
|
||||
|
||||
1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
|
||||
2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
|
||||
not quarantined.
|
||||
3. Build a multi-pass association map before mutation:
|
||||
- normalized legacy proxy entry;
|
||||
- projected legacy promoted-widget order;
|
||||
- host `widgets_values` value, preserving sparse holes;
|
||||
- repair strategy or failure reason;
|
||||
- whether the entry is a value widget or display-only preview exposure.
|
||||
4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
|
||||
is configured.
|
||||
5. On flush, re-resolve against current graph state, because clone/paste/load
|
||||
flows may have remapped or created nodes and links.
|
||||
6. If already represented by a linked `SubgraphInput`, consider the legacy
|
||||
entry resolved and consume it.
|
||||
7. Otherwise repair through existing subgraph input/link systems.
|
||||
8. If the entry is display-only preview surfacing, migrate it into the separate
|
||||
preview-exposure representation instead of creating a linked `SubgraphInput`.
|
||||
9. If value-widget repair fails, write inert quarantine metadata and warn.
|
||||
|
||||
The repair is idempotent. Pending plans store tuple/value data and re-check the
|
||||
current graph before applying mutations.
|
||||
|
||||
Legacy entries are classified as preview exposures when either:
|
||||
|
||||
- the legacy source name starts with `$$`; or
|
||||
- the source node resolves to a matching pseudo-preview widget, such as a
|
||||
`serialize: false` preview/video/audio UI widget.
|
||||
|
||||
Everything else is treated as a value-widget promotion candidate. An unresolved
|
||||
preview-shaped entry remains inert at runtime and is still persisted, because
|
||||
preview-capable pseudo-widgets and output media can be removed and re-added
|
||||
dynamically. It is not quarantined because it has no user value to preserve. A
|
||||
non-`$$` entry that cannot resolve to a source widget is a value-widget repair
|
||||
failure and follows the quarantine path unless it can resolve to a
|
||||
pseudo-preview widget.
|
||||
|
||||
## Proxy widget error quarantine
|
||||
|
||||
Valid legacy entries that cannot be repaired are persisted in
|
||||
`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
|
||||
not hydrate runtime promoted widgets, do not participate in execution, and are
|
||||
not used for app-mode/favorites identity.
|
||||
|
||||
Quarantine entries preserve enough information to avoid data loss and support
|
||||
future tooling:
|
||||
|
||||
```ts
|
||||
type ProxyWidgetErrorQuarantineEntry = {
|
||||
originalEntry: ProxyWidgetTuple
|
||||
reason:
|
||||
| 'missingSourceNode'
|
||||
| 'missingSourceWidget'
|
||||
| 'missingSubgraphInput'
|
||||
| 'ambiguousSubgraphInput'
|
||||
| 'unlinkedSourceWidget'
|
||||
| 'primitiveBypassFailed'
|
||||
hostValue?: TWidgetValue
|
||||
attemptedAtVersion: 1
|
||||
}
|
||||
```
|
||||
|
||||
Unresolved legacy UI selections/favorites are dropped with `console.warn`.
|
||||
Workflow-level promotion/value intent is preserved by
|
||||
`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
|
||||
|
||||
## Primitive-node repair
|
||||
|
||||
Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
|
||||
serve nearly the same purpose as subgraph inputs: they provide a widget value to
|
||||
one or more target widget inputs. The migration repairs this expected legacy
|
||||
shape in the first migration rather than quarantining it by default.
|
||||
|
||||
Primitive repair:
|
||||
|
||||
- coalesces exact duplicate legacy entries during planning;
|
||||
- uses the primitive node's user title as the base input name when the node was
|
||||
renamed, otherwise the primitive output widget name;
|
||||
- applies existing naming behavior and `nextUniqueName(...)` for collisions;
|
||||
- uses the existing primitive merge/config compatibility logic;
|
||||
- creates one `SubgraphInput` for the primitive fanout;
|
||||
- reconnects every former primitive output target to that input in target
|
||||
order, using standard connect/disconnect APIs;
|
||||
- applies the host value when one exists, otherwise seeds from the source
|
||||
primitive value;
|
||||
- leaves the primitive node and its widget value in place, but disconnected and
|
||||
inert.
|
||||
|
||||
Primitive repair is all-or-quarantine. If any target cannot be validated or
|
||||
reconnected, the migration does not leave a partial rewrite; it quarantines the
|
||||
entry with `hostValue` and logs the reason.
|
||||
|
||||
## Serialization
|
||||
|
||||
After repair/quarantine:
|
||||
|
||||
- `properties.proxyWidgets` is omitted for repaired entries;
|
||||
- display-only preview entries are omitted from `properties.proxyWidgets` and
|
||||
emitted through `properties.previewExposures`;
|
||||
- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
|
||||
- preview exposures do not carry quarantine values because they do not own user
|
||||
values; unresolved preview exposures remain inert in `previewExposures`;
|
||||
- host `widgets_values` contains host-owned values only for canonical host
|
||||
widgets, not source-owned defaults or interior persistence copies;
|
||||
- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
|
||||
- array-form `widgets_values` remains for now.
|
||||
|
||||
Preview exposures are display-only UI metadata. They drive host canvas/app-mode
|
||||
preview rendering, but they do not create prompt inputs, do not create
|
||||
`widgets_values`, do not alter node execution order, do not become executable
|
||||
graph edges, and do not participate in prompt serialization. Runtime mapping
|
||||
from backend `display_node`/output messages to a host preview exposure is a UI
|
||||
projection only.
|
||||
|
||||
The old `SubgraphNode.serialize()` behavior that copied exterior promoted
|
||||
values into connected interior widgets is removed. A temporary TODO should mark
|
||||
that removal point until the migration is proven stable. Host values are
|
||||
serialized through standard subgraph-input widgets instead.
|
||||
|
||||
Longer term, `widgets_values` should move from array order to an object/map
|
||||
keyed by stable widget name, but that migration is out of scope for this
|
||||
decision.
|
||||
|
||||
## App mode, builder, and favorites
|
||||
|
||||
The runtime migration and UI identity migration ship in the same slice. The UI
|
||||
must not persist promoted selections by source node/widget identity after this
|
||||
change.
|
||||
|
||||
Canonical UI identity is:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
Legacy source-identity selections are migrated when they resolve through the
|
||||
standard input created or confirmed by the migration. Unresolved selections are
|
||||
dropped with a warning.
|
||||
|
||||
Preview exposure output selections are also host-scoped and must not persist
|
||||
interior source node identity. Canonical preview/output identity is:
|
||||
|
||||
```ts
|
||||
type PreviewExposureUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
previewName: string
|
||||
}
|
||||
```
|
||||
|
||||
The UI references the explicit preview exposure itself. This keeps subgraphs
|
||||
opaque: consumers select the host boundary contract, not the interior node that
|
||||
currently supplies media. Legacy output selections that refer to interior
|
||||
preview source nodes may migrate if they resolve to a preview-exposure chain;
|
||||
otherwise they are dropped with `console.warn`. There is no separate preview UI
|
||||
quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
|
||||
## Considered options
|
||||
|
||||
### Keep `proxyWidgets` as canonical serialized topology
|
||||
|
||||
Rejected. This preserves two representations for the same concept and keeps
|
||||
source-widget identity in the value-ownership path.
|
||||
|
||||
### Preserve bare promoted widgets as degraded runtime state
|
||||
|
||||
Rejected. This would avoid some migration complexity, but it perpetuates the
|
||||
ambiguity that caused host/source value bugs and makes ECS identity less clear.
|
||||
|
||||
### Quarantine primitive-node promotions by default
|
||||
|
||||
Rejected. Primitive-node proxy promotions are expected legacy workflows, and
|
||||
quarantining them would break users unnecessarily. They are repaired by bypassing
|
||||
the primitive node when the repair can be validated all-or-nothing.
|
||||
|
||||
### Migrate `widgets_values` to object/map form now
|
||||
|
||||
Rejected for this slice. Name-keyed object form is the desired long-term
|
||||
direction, but combining it with the promotion migration increases blast radius
|
||||
for existing workflow consumers that still assume array order.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Promoted widget values become host-instance-owned and ECS-compatible.
|
||||
- Source widgets remain metadata/default providers, not persistence carriers.
|
||||
- Legacy workflows are repaired toward one standard representation.
|
||||
- Quarantine preserves unrepaired valid legacy data without reintroducing bare
|
||||
runtime promotion.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Appendix: Before and after flows
|
||||
|
||||
This appendix visualizes the ownership and migration flows described in
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
## Before: proxy widgets and linked inputs overlap
|
||||
|
||||
Historically, promoted widgets could be represented both as serialized
|
||||
`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
|
||||
reads could collapse back to the interior source widget, while host
|
||||
`widgets_values` could also carry an exterior value for the same promoted UI.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
|
||||
promotionStore --> sourceWidget[Interior source widget]
|
||||
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
|
||||
sourceWidget --> hostWidget
|
||||
hostValues --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
hostWidget -. may copy value back .-> sourceWidget
|
||||
sourceWidget -. shared by host instances .-> otherHost[Another host instance]
|
||||
|
||||
classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
|
||||
class proxyWidgets,promotionStore legacy
|
||||
class sourceWidget,hostValues ambiguous
|
||||
class linkedInput,hostWidget canonical
|
||||
```
|
||||
|
||||
Key problems in the old flow:
|
||||
|
||||
- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
|
||||
the same promotion.
|
||||
- Interior source widgets supplied both schema metadata and, in some flows,
|
||||
persisted host values.
|
||||
- Multiple host instances of the same subgraph could stomp one another through
|
||||
the shared interior widget value.
|
||||
- Display-only previews were mixed into widget-promotion language even though
|
||||
they do not own values or feed prompt serialization.
|
||||
|
||||
## After: linked inputs are the promoted-widget boundary
|
||||
|
||||
Promoted value widgets are now represented only as standard linked
|
||||
`SubgraphInput` widgets. The source widget remains the schema/default provider,
|
||||
but the host `SubgraphNode` owns the promoted value.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
subgraphInterface --> subgraphInput[SubgraphInput.name]
|
||||
subgraphInput --> hostWidget[Host-scoped widget entity]
|
||||
hostValues --> hostWidget
|
||||
sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
|
||||
schema --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
|
||||
hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
|
||||
sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
|
||||
sourceWidget -. no host value ownership .-> schema
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
|
||||
class sourceWidget,schema,diagnostics metadata
|
||||
class workflow,hostValues persisted
|
||||
```
|
||||
|
||||
Canonical ownership after the migration:
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- `SubgraphInput.name` is stable identity; labels and localized names are
|
||||
display-only.
|
||||
- Host values win during repair, persistence, and prompt serialization.
|
||||
- Source widgets provide metadata and defaults only.
|
||||
- Canonical saves omit repaired `properties.proxyWidgets` entries.
|
||||
|
||||
## Legacy load migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
|
||||
repair builds a plan before mutating graph state, then re-resolves against the
|
||||
current graph when node IDs and links are stable.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
start[Load workflow] --> parse{Parse properties.proxyWidgets}
|
||||
parse -->|invalid raw data| invalid[console.error and ignore]
|
||||
parse -->|valid tuples| plan[Build repair plan]
|
||||
plan --> classify{Classify entry}
|
||||
|
||||
classify -->|value widget| valueRepair{Already linked SubgraphInput?}
|
||||
valueRepair -->|yes| consume[Consume legacy proxy entry]
|
||||
valueRepair -->|no| repair[Repair through subgraph input/link systems]
|
||||
repair --> repairResult{Repair succeeded?}
|
||||
repairResult -->|yes| consume
|
||||
repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
|
||||
|
||||
classify -->|primitive fanout| primitive[Validate all primitive targets]
|
||||
primitive --> primitiveResult{All targets reconnectable?}
|
||||
primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
|
||||
primitiveRepair --> consume
|
||||
primitiveResult -->|no| quarantine
|
||||
|
||||
classify -->|display-only preview| preview[Create / keep previewExposures entry]
|
||||
preview --> consume
|
||||
|
||||
consume --> save[Canonical save]
|
||||
quarantine --> save
|
||||
save --> omit[Omit repaired entries from proxyWidgets]
|
||||
save --> keepQuarantine[Persist unrepaired value intent in quarantine]
|
||||
save --> keepPreview[Persist previews in previewExposures]
|
||||
|
||||
classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef error fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
|
||||
class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
|
||||
class quarantine,keepQuarantine warn
|
||||
class invalid error
|
||||
```
|
||||
|
||||
## Preview exposures are separate from value widgets
|
||||
|
||||
Display-only previews, such as `$$canvas-image-preview`, are not promoted
|
||||
widgets. They have host-scoped serialized identity, but they do not create
|
||||
prompt inputs, do not create `widgets_values`, and do not own user values.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
|
||||
previewExposures --> exposure[PreviewExposure.name]
|
||||
exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
|
||||
sourceLocator --> runtimePreview[Runtime preview/output state]
|
||||
runtimePreview --> hostCanvas[Host canvas / app-mode preview]
|
||||
|
||||
exposure --> uiIdentity[hostNodeLocator + previewName]
|
||||
runtimePreview -. UI projection only .-> hostCanvas
|
||||
previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
|
||||
previewExposures -. no value widget .-> noValue[No widgets_values entry]
|
||||
previewExposures -. no graph edge .-> noEdge[No executable graph edge]
|
||||
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
|
||||
class noPrompt,noValue,noEdge noValue
|
||||
class hostNode persisted
|
||||
```
|
||||
|
||||
For nested subgraphs, preview exposures chain across immediate host boundaries
|
||||
instead of persisting flattened deep paths.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
|
||||
outerExposure --> innerHost[Immediate inner SubgraphNode]
|
||||
innerHost --> innerExposure[Inner previewExposures entry]
|
||||
innerExposure --> deepestPreview[Interior preview source]
|
||||
deepestPreview --> media[Resolved media]
|
||||
|
||||
outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
|
||||
outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class outerHost,innerHost boundary
|
||||
class outerExposure,innerExposure,deepestPreview,media preview
|
||||
class opaque note
|
||||
```
|
||||
|
||||
## Serialization summary
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
|
||||
canonical --> values[widgets_values for host-owned values]
|
||||
canonical --> previews[properties.previewExposures]
|
||||
canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
|
||||
canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
|
||||
|
||||
inputs --> valueWidgets[Promoted value widgets]
|
||||
values --> valueWidgets
|
||||
previews --> previewUi[Display-only preview UI]
|
||||
quarantine --> futureTooling[Future recovery tooling]
|
||||
|
||||
valueWidgets --> prompt[Prompt serialization]
|
||||
previewUi -. not serialized into prompt .-> prompt
|
||||
quarantine -. inert .-> prompt
|
||||
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class inputs,values,valueWidgets,prompt,canonical canonical
|
||||
class previews,previewUi,quarantine,futureTooling inert
|
||||
class noProxy removed
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Appendix: Removing `disambiguatingSourceNodeId`
|
||||
|
||||
This appendix explains where the existing promotion system needs
|
||||
`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
|
||||
chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
removes the pattern from promoted-widget identity.
|
||||
|
||||
## Why the disambiguator exists
|
||||
|
||||
The legacy promotion model identifies a promoted widget by source location:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetSource = {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
```
|
||||
|
||||
`sourceNodeId` is the immediate interior node visible from the host subgraph.
|
||||
That is not always the original widget owner. When promotions pass through
|
||||
nested subgraphs, two promoted widgets can have the same immediate
|
||||
`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
|
||||
`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
|
||||
can choose the right promoted view.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
|
||||
middleNode --> middleWidgetA[Promoted widget view: text]
|
||||
middleNode --> middleWidgetB[Promoted widget view: text]
|
||||
middleWidgetA --> leafA[Leaf source node 17 / widget text]
|
||||
middleWidgetB --> leafB[Leaf source node 42 / widget text]
|
||||
|
||||
oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
|
||||
oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
|
||||
middleWidgetA -. requires .-> oldKeyA
|
||||
middleWidgetB -. requires .-> oldKeyB
|
||||
|
||||
classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
|
||||
|
||||
class outerHost host
|
||||
class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
|
||||
class leafA,leafB leaf
|
||||
```
|
||||
|
||||
The disambiguator is therefore not a domain concept. It is compensating for an
|
||||
identity model that asks host UI state to identify private nested internals.
|
||||
|
||||
## Existing places that need it
|
||||
|
||||
| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
|
||||
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
|
||||
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
|
||||
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
|
||||
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
|
||||
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
|
||||
|
||||
## New promoted-widget identity
|
||||
|
||||
ADR 0009 moves promoted value identity to the host boundary:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
The canonical widget is owned by a `SubgraphInput` on the host
|
||||
`SubgraphNode`. The host widget no longer needs to identify the deepest source
|
||||
node to preserve value identity. The source widget is consulted for schema,
|
||||
defaults, diagnostics, and migration, but it is not the value owner.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
|
||||
host --> inputB[SubgraphInput.name: negative_prompt]
|
||||
inputA --> hostWidgetA[Host-owned widget entity]
|
||||
inputB --> hostWidgetB[Host-owned widget entity]
|
||||
|
||||
hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
|
||||
hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
|
||||
|
||||
identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
|
||||
identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
|
||||
sourceA -. not part of host value key .-> identityA
|
||||
sourceB -. not part of host value key .-> identityB
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
|
||||
class sourceA,sourceB metadata
|
||||
```
|
||||
|
||||
This is the same rule the subgraph interface already uses: `name` is stable
|
||||
identity, and `label` / `localized_name` are display-only.
|
||||
|
||||
## How the new form removes each need
|
||||
|
||||
| Previous disambiguation site | New canonical replacement |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
|
||||
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
|
||||
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
|
||||
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
|
||||
| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
|
||||
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
|
||||
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
|
||||
|
||||
## Boundary-by-boundary nested flow
|
||||
|
||||
The new form avoids flattened deep source paths. Each host boundary exposes its
|
||||
own named input, and the next outer host links to that immediate boundary
|
||||
contract.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
|
||||
innerInput --> innerHostWidget[Inner host-owned widget]
|
||||
innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
|
||||
outerInput --> outerHostWidget[Outer host-owned widget]
|
||||
|
||||
innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
|
||||
outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
|
||||
leaf -. schema/default source .-> innerHostWidget
|
||||
leaf -. not persisted as outer value key .-> outerIdentity
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef source fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
|
||||
class leaf source
|
||||
```
|
||||
|
||||
Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
|
||||
widgets no longer require a persisted leaf-node disambiguator at the outer host.
|
||||
If the user exposes both, the collision is resolved when the host inputs are
|
||||
created by assigning distinct input names with the existing unique-name
|
||||
behavior.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Appendix: System comparison
|
||||
|
||||
This appendix compares the legacy promoted-widget systems with the canonical
|
||||
linked-input model chosen by
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
|
||||
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
|
||||
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
|
||||
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
|
||||
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
|
||||
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
|
||||
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
|
||||
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
|
||||
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
|
||||
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
|
||||
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
|
||||
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
|
||||
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
|
||||
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
|
||||
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
|
||||
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
|
||||
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
|
||||
|
||||
## Practical migration summary
|
||||
|
||||
| Legacy shape | New result |
|
||||
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
|
||||
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
|
||||
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
|
||||
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
|
||||
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
|
||||
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
|
||||
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |
|
||||
@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
||||
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
||||
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
||||
|
||||
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
|
||||
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
|
||||
source node/widget identity is preserved only as migration and diagnostic
|
||||
metadata.
|
||||
|
||||
### 2c. Read-only bridge for Node metadata
|
||||
|
||||
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
||||
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
||||
| SubgraphNavigationStore | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
source node/widget identity.
|
||||
|
||||
The World unifies these under branded entity IDs. But stores that use
|
||||
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
||||
reality — a widget is identified by its relationship to a node. Synthetic
|
||||
|
||||
@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
source node/widget identity is migration and diagnostic metadata only.
|
||||
|
||||
## 2. WidgetValueStore
|
||||
|
||||
**File:** `src/stores/widgetValueStore.ts`
|
||||
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
identity (`host node locator + SubgraphInput.name`) instead of interior source
|
||||
identity.
|
||||
|
||||
## 6. Extraction Map
|
||||
|
||||
|
||||
@@ -404,26 +404,21 @@ Whichever candidate is chosen:
|
||||
instance-specific state beyond inputs — must remain reachable. This is a
|
||||
constraint, not a current requirement.
|
||||
|
||||
### Recommendation and decision criteria
|
||||
### Decision
|
||||
|
||||
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
|
||||
truth: promotion is adding a typed input to a function signature. The type
|
||||
system already handles widget creation for typed inputs. Building a parallel
|
||||
mechanism for "promoted widgets" is building a second, narrower version of
|
||||
something the system already does.
|
||||
[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
chooses Candidate A for promoted value widgets. It eliminates an entire
|
||||
subsystem by recognizing a structural truth: promotion is adding a typed input
|
||||
to a function signature. The type system already handles widget creation for
|
||||
typed inputs. Building a parallel mechanism for "promoted widgets" is building
|
||||
a second, narrower version of something the system already does.
|
||||
|
||||
The cost of A is a migration path for existing `proxyWidgets` serialization. On
|
||||
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
|
||||
inputs and boundary links. This is a one-time ratchet conversion — once
|
||||
loaded and re-saved, the workflow uses the new format.
|
||||
|
||||
**Choose B if** the team determines that promoted widgets must remain
|
||||
visually or behaviorally distinct from normal input widgets in ways the type →
|
||||
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
|
||||
the current release cycle's capacity.
|
||||
|
||||
**Decision needed before** Phase 3 of the ECS migration, when systems are
|
||||
introduced and the widget/connectivity architecture solidifies.
|
||||
load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
|
||||
into interface inputs and boundary links. Once loaded and re-saved, the workflow
|
||||
uses the new format. ADR 0009 separates display-only preview exposures from
|
||||
promoted value widgets; those previews use their own host-scoped serialized
|
||||
representation instead of linked `SubgraphInput` widgets.
|
||||
|
||||
---
|
||||
|
||||
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
|
||||
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
|
||||
see no change.
|
||||
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ---------------------------------------- |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ------------------------------------------ |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
|
||||
|
||||
The "ratchet conversion" pattern: load any supported format, normalize to the
|
||||
internal model. The system accepts old formats indefinitely but produces the
|
||||
current format on save.
|
||||
The migration pattern: load any supported format and normalize to the internal
|
||||
model. The system accepts old formats indefinitely but produces the current
|
||||
format on save.
|
||||
|
||||
### Widget identity at the boundary
|
||||
|
||||
@@ -511,13 +506,12 @@ SubgraphIO {
|
||||
}
|
||||
```
|
||||
|
||||
If Candidate A (connections-only promotion) is chosen: promoted widgets become
|
||||
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
|
||||
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
|
||||
migration). On save, `proxyWidgets` is no longer written.
|
||||
|
||||
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
|
||||
serialized in its current format.
|
||||
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
|
||||
widgets: they become interface inputs, serialized as additional `SubgraphIO`
|
||||
entries. On load, legacy value-widget `proxyWidgets` data is converted to
|
||||
interface inputs and boundary links. On save, repaired `proxyWidgets` entries
|
||||
are no longer written. Display-only preview exposures use separate
|
||||
host-scoped `previewExposures` serialization.
|
||||
|
||||
### Backward-compatible loading contract
|
||||
|
||||
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
|
||||
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
|
||||
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
|
||||
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
|
||||
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
|
||||
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
|
||||
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
|
||||
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
|
||||
|
||||
|
||||