Compare commits
146 Commits
coderabbit
...
ext-api/i-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877286adbb | ||
|
|
a441da79f3 | ||
|
|
b116dc01c5 | ||
|
|
7fb6c17dc6 | ||
|
|
e775e76bda | ||
|
|
f78064e2ea | ||
|
|
4dda5a70b9 | ||
|
|
40c4fdae4c | ||
|
|
25c76b98cf | ||
|
|
1975967a4e | ||
|
|
77a7dee3af | ||
|
|
ce9f61e99c | ||
|
|
f25ef80f48 | ||
|
|
b371bd97f1 | ||
|
|
baa5af0ac8 | ||
|
|
89080d0a1e | ||
|
|
5638744ea7 | ||
|
|
74ce30a2b7 | ||
|
|
937f3428ab | ||
|
|
5eb64a9e04 | ||
|
|
96bb939a69 | ||
|
|
ce5910217d | ||
|
|
c023e29d86 | ||
|
|
daa4da6619 | ||
|
|
9adfa9efc2 | ||
|
|
9c4fab20d9 | ||
|
|
9cba5cd08a | ||
|
|
8ee8bf6d61 | ||
|
|
1a2c6ac8f3 | ||
|
|
77954e3913 | ||
|
|
134b0a69f3 | ||
|
|
ef4d6ca1b8 | ||
|
|
5929987578 | ||
|
|
08fdd0ff29 | ||
|
|
1f8fc26019 | ||
|
|
9412716b27 | ||
|
|
12a170363d | ||
|
|
b1d149f660 | ||
|
|
13e2e3a607 | ||
|
|
a337d1cfbb | ||
|
|
d6aa562e7a | ||
|
|
909bbb660b | ||
|
|
2cc1457596 | ||
|
|
616a30ddb3 | ||
|
|
f182d1ff96 | ||
|
|
524830023d | ||
|
|
d70ead814d | ||
|
|
542256eeec | ||
|
|
5df5ee1d0b | ||
|
|
2f76a931a8 | ||
|
|
dcdc9e7bfa | ||
|
|
3f639da07d | ||
|
|
d66f989a96 | ||
|
|
de7730b67b | ||
|
|
528d014e15 | ||
|
|
b4fe848527 | ||
|
|
e4f2feb6f8 | ||
|
|
e9d51335c2 | ||
|
|
06fb27d233 | ||
|
|
c2ab350cb7 | ||
|
|
4eaf898cc2 | ||
|
|
83132ab5a1 | ||
|
|
9ab998003c | ||
|
|
42a1ba05f4 | ||
|
|
a059009dce | ||
|
|
0ffb475d74 | ||
|
|
14349fe23f | ||
|
|
73ba37a78e | ||
|
|
ca909b2832 | ||
|
|
5bf589f8d1 | ||
|
|
6b6d4773d9 | ||
|
|
a86dc1cc2b | ||
|
|
24d893d401 | ||
|
|
3d09f89251 | ||
|
|
dd5335df7c | ||
|
|
ee0537fdb5 | ||
|
|
d5d5692928 | ||
|
|
e56187adf3 | ||
|
|
446d0a216e | ||
|
|
bd4d195230 | ||
|
|
ff314491da | ||
|
|
b3b3b10fea | ||
|
|
fa3229b402 | ||
|
|
2f102353fa | ||
|
|
df921f3512 | ||
|
|
a058a410ac | ||
|
|
300be13a4c | ||
|
|
f069f540ce | ||
|
|
d4323d7ab1 | ||
|
|
c5d7fb113f | ||
|
|
6345359ca8 | ||
|
|
b2e9c8f749 | ||
|
|
20daf22a68 | ||
|
|
f83510a223 | ||
|
|
ba636765a7 | ||
|
|
d0614e595f | ||
|
|
8e71fd0436 | ||
|
|
8bc2ff0800 | ||
|
|
ceec47df88 | ||
|
|
aa0b00953b | ||
|
|
7b9ea4a01f | ||
|
|
e292976f8d | ||
|
|
a0478f66ea | ||
|
|
52146d918f | ||
|
|
fa0079dfb5 | ||
|
|
f10990df3a | ||
|
|
ccfd53bdf5 | ||
|
|
8da221b5db | ||
|
|
e74250fd8a | ||
|
|
bf272a784d | ||
|
|
e25e210933 | ||
|
|
5b05f2b793 | ||
|
|
3476d06fc9 | ||
|
|
c1748c6fe3 | ||
|
|
9a6fff645d | ||
|
|
2de2e07b36 | ||
|
|
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 |
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
|
||||
#
|
||||
# Runs on any PR touching extension-api declaration files, extension-api-v2
|
||||
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
|
||||
#
|
||||
# Two jobs:
|
||||
# test — vitest run against src/extension-api-v2/__tests__/
|
||||
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
|
||||
# blast_radius ≥ 2.0 category is missing a stub triple)
|
||||
#
|
||||
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
|
||||
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
|
||||
name: 'CI: Tests Extension API'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Extension API tests (vitest)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run extension-api test suite
|
||||
run: pnpm test:extension-api
|
||||
|
||||
- name: Run with coverage (push only)
|
||||
if: github.event_name == 'push'
|
||||
run: pnpm test:extension-api:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.event_name == 'push'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
flags: extension-api
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
compat-floor:
|
||||
name: Compat-floor gate (blast_radius ≥ 2.0)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Check compat floor
|
||||
run: python3 scripts/check-compat-floor.py
|
||||
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
|
||||
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.
|
||||
97
.github/workflows/extension-api-publish.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# Description: Publish @comfyorg/extension-api to npm with provenance attestation.
|
||||
#
|
||||
# Triggered by a tag push matching 'extension-api-v*' (e.g. extension-api-v0.1.0).
|
||||
# Also supports workflow_dispatch for a manual dry-run (set dry_run: true).
|
||||
#
|
||||
# Prerequisites (one-time human setup):
|
||||
# - NPM_TOKEN secret must be set in the repo/org settings with publish
|
||||
# access to the @comfyorg scope on npmjs.com.
|
||||
# - The @comfyorg npm scope already exists (used by @comfyorg/comfyui-frontend).
|
||||
#
|
||||
# PKG4.D4 (MIG1 / Phase A — surface-only shim)
|
||||
name: 'Extension API: Publish'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'extension-api-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run — build and verify without publishing'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create GitHub Release
|
||||
id-token: write # needed for npm provenance via OIDC
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish @comfyorg/extension-api
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # full history for release notes
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Setup npm registry
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter @comfyorg/extension-api build
|
||||
|
||||
- name: Typecheck package
|
||||
run: pnpm --filter @comfyorg/extension-api typecheck
|
||||
|
||||
- name: Verify package version matches tag
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}" # e.g. extension-api-v0.1.0
|
||||
PKG_VERSION=$(node -p "require('./packages/extension-api/package.json').version")
|
||||
TAG_VERSION="${TAG#extension-api-v}" # strip prefix → 0.1.0
|
||||
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "::error::Tag '$TAG' implies version '$TAG_VERSION' but packages/extension-api/package.json has '$PKG_VERSION'. Update the package.json before tagging."
|
||||
exit 1
|
||||
fi
|
||||
echo "Version check passed: $PKG_VERSION"
|
||||
|
||||
- name: Publish to npm (with provenance)
|
||||
if: github.event_name == 'push' || !inputs.dry_run
|
||||
run: |
|
||||
cd packages/extension-api
|
||||
npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Dry-run report
|
||||
if: inputs.dry_run
|
||||
run: |
|
||||
echo "=== DRY RUN — would publish ==="
|
||||
cd packages/extension-api
|
||||
npm pack --dry-run
|
||||
echo "=== End dry run ==="
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const tag = context.ref.replace('refs/tags/', '')
|
||||
const { data: release } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
name: tag,
|
||||
generate_release_notes: true,
|
||||
draft: false,
|
||||
prerelease: context.ref.includes('-alpha') || context.ref.includes('-beta') || context.ref.includes('-rc')
|
||||
})
|
||||
console.log(`Release created: ${release.html_url}`)
|
||||
65
.github/workflows/extension-api-typecheck.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# Description: Typecheck and build the @comfyorg/extension-api package.
|
||||
# Runs on PRs and pushes touching the public type surface, the core .v2.ts
|
||||
# implementations, or the package scaffold — so regressions in the published
|
||||
# contract are caught before merge.
|
||||
#
|
||||
# PKG4.D3 (MIG1 / Phase A — surface-only shim)
|
||||
name: 'Extension API: Typecheck'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extensions/core/*.v2.ts'
|
||||
- 'src/services/extension-api-service.ts'
|
||||
- 'packages/extension-api/**'
|
||||
- '.github/workflows/extension-api-*.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extensions/core/*.v2.ts'
|
||||
- 'src/services/extension-api-service.ts'
|
||||
- 'packages/extension-api/**'
|
||||
- '.github/workflows/extension-api-*.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Build + typecheck @comfyorg/extension-api
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build package (emit declarations)
|
||||
run: pnpm --filter @comfyorg/extension-api build
|
||||
|
||||
- name: Typecheck package
|
||||
run: pnpm --filter @comfyorg/extension-api typecheck
|
||||
|
||||
- name: Smoke-test consumer (tsc --noEmit on minimal extension)
|
||||
# Verifies the published types are consumable from an external module
|
||||
# that imports from '@comfyorg/extension-api'. Uses a minimal fixture
|
||||
# checked in to packages/extension-api/test/smoke/.
|
||||
run: |
|
||||
cd packages/extension-api
|
||||
if [ -d test/smoke ]; then
|
||||
pnpm exec tsc --noEmit --project test/smoke/tsconfig.json
|
||||
else
|
||||
echo "No smoke test found — skipping (add packages/extension-api/test/smoke/ to enable)"
|
||||
fi
|
||||
@@ -6,6 +6,7 @@
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": [
|
||||
"packages/extension-api/build/**",
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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 |
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { t, hasKey, translationKeys } from './translations'
|
||||
|
||||
describe('translations', () => {
|
||||
describe('pricing.plan.creator keys', () => {
|
||||
it('feature1 key exists with correct English text', () => {
|
||||
expect(hasKey('pricing.plan.creator.feature1')).toBe(true)
|
||||
expect(t('pricing.plan.creator.feature1')).toBe('Import your own LoRAs')
|
||||
})
|
||||
|
||||
it('feature1 key returns correct zh-CN translation', () => {
|
||||
expect(t('pricing.plan.creator.feature1', 'zh-CN')).toBe(
|
||||
'导入你自己的 LoRA'
|
||||
)
|
||||
})
|
||||
|
||||
it('feature2 key does not exist (removed concurrent API jobs entry)', () => {
|
||||
expect(hasKey('pricing.plan.creator.feature2')).toBe(false)
|
||||
})
|
||||
|
||||
it('translationKeys array does not include removed creator feature2', () => {
|
||||
expect(translationKeys).not.toContain('pricing.plan.creator.feature2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pricing.plan.pro keys', () => {
|
||||
it('feature1 key exists with correct English text', () => {
|
||||
expect(hasKey('pricing.plan.pro.feature1')).toBe(true)
|
||||
expect(t('pricing.plan.pro.feature1')).toBe(
|
||||
'Longer workflow runtime (up to 1 hour)'
|
||||
)
|
||||
})
|
||||
|
||||
it('feature1 key returns correct zh-CN translation', () => {
|
||||
expect(t('pricing.plan.pro.feature1', 'zh-CN')).toBe(
|
||||
'更长工作流运行时长(最长 1 小时)'
|
||||
)
|
||||
})
|
||||
|
||||
it('feature2 key does not exist (removed concurrent API jobs entry)', () => {
|
||||
expect(hasKey('pricing.plan.pro.feature2')).toBe(false)
|
||||
})
|
||||
|
||||
it('translationKeys array does not include removed pro feature2', () => {
|
||||
expect(translationKeys).not.toContain('pricing.plan.pro.feature2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('t() function defaults', () => {
|
||||
it('defaults locale to en when not provided', () => {
|
||||
expect(t('pricing.plan.creator.feature1')).toBe(
|
||||
t('pricing.plan.creator.feature1', 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to en when locale translation is missing', () => {
|
||||
// All keys in this file have both en and zh-CN, but the fallback
|
||||
// behaviour is part of the contract: t() should return en if the
|
||||
// locale value is nullish.
|
||||
const englishValue = t('pricing.plan.pro.feature1', 'en')
|
||||
expect(englishValue).toBe('Longer workflow runtime (up to 1 hour)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasKey()', () => {
|
||||
it('returns true for known keys', () => {
|
||||
expect(hasKey('hero.title')).toBe(true)
|
||||
expect(hasKey('pricing.plan.creator.feature1')).toBe(true)
|
||||
expect(hasKey('pricing.plan.pro.feature1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for completely unknown keys', () => {
|
||||
expect(hasKey('nonexistent.key')).toBe(false)
|
||||
expect(hasKey('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for the two removed concurrent-API-jobs keys', () => {
|
||||
// Regression guard: these keys carried "3 concurrent API jobs" and
|
||||
// "5 concurrent API jobs" copy that was intentionally removed.
|
||||
expect(hasKey('pricing.plan.creator.feature2')).toBe(false)
|
||||
expect(hasKey('pricing.plan.pro.feature2')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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': '所有演示' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
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,48 +133,27 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets does not filter by exclude_tags (parameter removed)', async ({
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
// Regression guard: exclude_tags filtering was removed from handleListAssets
|
||||
// and getFilteredAssets. Passing exclude_tags must NOT exclude any assets —
|
||||
// all assets matching include_tags (or all assets when include_tags is
|
||||
// absent) should be returned regardless of the exclude_tags param.
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'tagged-input',
|
||||
tags: ['input', 'extra-tag']
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input&exclude_tags=extra-tag`
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
// Both assets share the 'input' include_tag. With exclude_tags removed,
|
||||
// neither should be hidden — the response must contain both.
|
||||
expect(data.assets).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('GET /assets with empty include_tags returns all assets', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
// When include_tags is absent or empty the helper returns the full store.
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(`${comfyPage.url}/api/assets`)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets).toHaveLength(3)
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
// These tests verify the current state of getMimeType after audio MIME types
|
||||
// (mp3, flac, ogg, opus) were removed in favour of 'application/octet-stream'.
|
||||
|
||||
test.describe('getMimeType', { tag: '@unit' }, () => {
|
||||
test.describe('image formats (still supported)', () => {
|
||||
test('returns image/png for .png files', () => {
|
||||
expect(getMimeType('photo.png')).toBe('image/png')
|
||||
})
|
||||
|
||||
test('returns image/jpeg for .jpg files', () => {
|
||||
expect(getMimeType('photo.jpg')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
test('returns image/jpeg for .jpeg files', () => {
|
||||
expect(getMimeType('photo.jpeg')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
test('returns image/webp for .webp files', () => {
|
||||
expect(getMimeType('image.webp')).toBe('image/webp')
|
||||
})
|
||||
|
||||
test('returns image/svg+xml for .svg files', () => {
|
||||
expect(getMimeType('icon.svg')).toBe('image/svg+xml')
|
||||
})
|
||||
|
||||
test('returns image/avif for .avif files', () => {
|
||||
expect(getMimeType('image.avif')).toBe('image/avif')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('video formats (still supported)', () => {
|
||||
test('returns video/webm for .webm files', () => {
|
||||
expect(getMimeType('video.webm')).toBe('video/webm')
|
||||
})
|
||||
|
||||
test('returns video/mp4 for .mp4 files', () => {
|
||||
expect(getMimeType('video.mp4')).toBe('video/mp4')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('other formats (still supported)', () => {
|
||||
test('returns application/json for .json files', () => {
|
||||
expect(getMimeType('data.json')).toBe('application/json')
|
||||
})
|
||||
|
||||
test('returns model/gltf-binary for .glb files', () => {
|
||||
expect(getMimeType('model.glb')).toBe('model/gltf-binary')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('audio formats (no longer supported — regression guard)', () => {
|
||||
// These formats were explicitly removed from getMimeType. They must now
|
||||
// fall through to the generic octet-stream default, not return audio/*
|
||||
// MIME types.
|
||||
|
||||
test('returns application/octet-stream for .mp3 files', () => {
|
||||
expect(getMimeType('audio.mp3')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for .flac files', () => {
|
||||
expect(getMimeType('audio.flac')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for .ogg files', () => {
|
||||
expect(getMimeType('audio.ogg')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for .opus files', () => {
|
||||
expect(getMimeType('audio.opus')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('unknown / fallback extensions', () => {
|
||||
test('returns application/octet-stream for unknown extensions', () => {
|
||||
expect(getMimeType('file.xyz')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for files with no extension', () => {
|
||||
expect(getMimeType('README')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
test('returns application/octet-stream for empty string', () => {
|
||||
expect(getMimeType('')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('case insensitivity', () => {
|
||||
test('handles uppercase .PNG extension', () => {
|
||||
expect(getMimeType('IMAGE.PNG')).toBe('image/png')
|
||||
})
|
||||
|
||||
test('handles uppercase .JPG extension', () => {
|
||||
expect(getMimeType('PHOTO.JPG')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
test('handles uppercase .MP4 extension', () => {
|
||||
expect(getMimeType('VIDEO.MP4')).toBe('video/mp4')
|
||||
})
|
||||
|
||||
test('handles uppercase .WEBM extension', () => {
|
||||
expect(getMimeType('VIDEO.WEBM')).toBe('video/webm')
|
||||
})
|
||||
|
||||
// Boundary case: confirm audio formats are not supported regardless of case
|
||||
test('returns application/octet-stream for uppercase .MP3 extension', () => {
|
||||
expect(getMimeType('AUDIO.MP3')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('filenames with multiple dots', () => {
|
||||
test('resolves extension from last segment', () => {
|
||||
expect(getMimeType('my.file.name.png')).toBe('image/png')
|
||||
})
|
||||
|
||||
test('returns octet-stream when final segment is not a known extension', () => {
|
||||
expect(getMimeType('archive.tar.gz')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
|
||||
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. |
|
||||
108
docs/adr/0010-deprecate-node-level-serialization-control.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 10. Deprecate Node-Level Serialization Control
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The v2 extension API initially included `node.on('beforeSerialize', handler)` as a migration path from v1 patterns like `node.onSerialize` and `nodeType.prototype.serialize` patching. This allowed extensions to:
|
||||
|
||||
1. **Append extra fields** to the serialized node object
|
||||
2. **Transform the entire serialized object** via a replace function
|
||||
|
||||
However, during design review (PR #12142), we questioned whether node-level serialization control is the right abstraction:
|
||||
|
||||
### The Problem
|
||||
|
||||
Node-level serialization control is fundamentally **wrong-layered**:
|
||||
|
||||
- **Extension state should live in widgets**, not as arbitrary fields on the node
|
||||
- Widget-level `beforeSerialize` already handles all legitimate use cases
|
||||
- Node-level hooks encourage storing extension state in ad-hoc `node.properties` or custom fields, which:
|
||||
- Breaks the clean separation between framework concerns and extension concerns
|
||||
- Creates hidden dependencies between serialization format and extension behavior
|
||||
- Makes migration and format evolution harder
|
||||
|
||||
### v1 Usage Analysis
|
||||
|
||||
Touch-point audit of `nodeType.prototype.serialize` and `node.onSerialize` patterns in the wild:
|
||||
|
||||
| Use Case | Proper v2 Alternative |
|
||||
| --------------------------- | --------------------------------------------------- |
|
||||
| Store extension state | Use widget values with `beforeSerialize` |
|
||||
| Persist per-instance config | Use `widget.setOption()` → `widget_options` sidecar |
|
||||
| Add metadata for export | Use a dedicated extension state widget |
|
||||
| Transform output format | Framework concern, not extension concern |
|
||||
|
||||
No use case requires node-level control that can't be better served by widget-level APIs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Deprecate `node.on('beforeSerialize')`** — mark as `@deprecated` with clear guidance pointing to widget-level alternatives. Remove in v1.0.
|
||||
|
||||
Widget-level serialization control (`widget.on('beforeSerialize')`) remains fully supported as the correct abstraction.
|
||||
|
||||
### Migration Path
|
||||
|
||||
Extensions currently using `node.on('beforeSerialize')` should:
|
||||
|
||||
1. **Store state in widgets** instead of arbitrary node fields
|
||||
2. **Use `widget.on('beforeSerialize')`** to control serialization per-widget
|
||||
3. **Use `widget.setOption()`** for per-instance configuration
|
||||
|
||||
Example migration:
|
||||
|
||||
```ts
|
||||
// BEFORE (v1 / deprecated v2)
|
||||
node.on('beforeSerialize', (e) => {
|
||||
e.data['my_extension_state'] = computeState()
|
||||
})
|
||||
|
||||
// AFTER (recommended v2)
|
||||
const stateWidget = node.addWidget('STRING', '_my_state', '', {
|
||||
hidden: true,
|
||||
serialize: true
|
||||
})
|
||||
stateWidget.on('beforeSerialize', (e) => {
|
||||
e.setSerializedValue(JSON.stringify(computeState()))
|
||||
})
|
||||
```
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add `@deprecated` tag to `node.on('beforeSerialize')` with migration guidance
|
||||
2. Add console.warn when the deprecated event is used (dev mode only)
|
||||
3. Update documentation to recommend widget-level patterns
|
||||
4. Remove `NodeBeforeSerializeEvent` type and handler in v1.0
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Cleaner architecture**: Extension state flows through widgets, the designed data channel
|
||||
- **Better debuggability**: Widget values are visible in workflow JSON at predictable locations
|
||||
- **Easier migration**: Future format changes only need to consider widget serialization
|
||||
- **Reduced API surface**: One less event type to maintain and document
|
||||
|
||||
### Negative
|
||||
|
||||
- **Migration burden**: Extensions using node-level serialization must refactor
|
||||
- **Potential edge cases**: Some exotic use cases may require workarounds
|
||||
|
||||
### Risk Mitigation
|
||||
|
||||
- Deprecation warning gives extension authors runway to migrate
|
||||
- Widget-level APIs are already more capable than node-level alternatives
|
||||
- The `@deprecated` tag and docs provide clear migration path
|
||||
|
||||
## Notes
|
||||
|
||||
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 11 for the full discussion thread.
|
||||
|
||||
Related decisions:
|
||||
|
||||
- Widget-level `beforeSerialize` remains the primary extension serialization hook
|
||||
- `setSerializeEnabled()` remains for simple static opt-out cases
|
||||
151
docs/adr/0010-widget-state-categories.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# 10. Widget State Categories
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The current widget system evolved organically and has several architectural issues:
|
||||
|
||||
- `options` is a constructor bag that gets reference-assigned, not copied
|
||||
- Instance properties (`widget.hidden`) and options bag (`widget.options.hidden`) are used interchangeably for the same concept
|
||||
- No clear separation between schema (type/name), runtime state (value/disabled), display hints (hidden), per-instance config (min/max), and serialization config
|
||||
- `Object.assign(this, safeValues)` in BaseWidget constructor means arbitrary properties can land on the instance
|
||||
- The dual `hidden` location causes bugs: Vue renderer reads `options.hidden`, canvas renderer reads `widget.hidden`
|
||||
|
||||
The ECS implementation uses 5 separate components (`WidgetComponentValue`, `WidgetComponentDisplay`, `WidgetComponentSchema`, `WidgetComponentSerialize`, `WidgetComponentContainer`), but this granularity is an implementation detail that shouldn't leak into the extension API.
|
||||
|
||||
### Forces
|
||||
|
||||
- Extensions need a simple, predictable mental model for widget state
|
||||
- The API should align with familiar patterns (Vue's component model)
|
||||
- ECS internals should remain hidden behind a facade
|
||||
- Migration from v1 patterns should be straightforward
|
||||
- The distinction between "presence of a constraint" (schema) and "value of a constraint" (prop) matters for primitives and subgraph widget merging
|
||||
|
||||
## Decision
|
||||
|
||||
Widget state is organized into **two categories**:
|
||||
|
||||
### Schema (Immutable)
|
||||
|
||||
Properties that cannot change after widget construction:
|
||||
|
||||
- `type` — widget type string (e.g., `'INT'`, `'STRING'`, `'COMBO'`)
|
||||
- `name` — widget name as declared in `INPUT_TYPES`
|
||||
- Presence of constraints (the _fact_ that min/max/step exist)
|
||||
- Default values
|
||||
|
||||
Schema comes from the node definition and is frozen at construction time.
|
||||
|
||||
### Props (Mutable, Per-Instance)
|
||||
|
||||
Everything else — all per-instance state that can change at runtime:
|
||||
|
||||
- `value` — the primary data (like Vue's `modelValue`)
|
||||
- `disabled`, `hidden`, `label`, `advanced`
|
||||
- Actual values of `min`, `max`, `step` (presence is schema, values are props)
|
||||
- `serialize` flag
|
||||
- `callback`, `draw`, `mouse`, `computeSize` (functions are values in JS)
|
||||
|
||||
Props follow one-way data flow: systems mutate props, views observe them.
|
||||
|
||||
### Model Value Convention
|
||||
|
||||
`value` is special only by convention, not by nature:
|
||||
|
||||
- It serializes to workflow JSON (`widgets_values`)
|
||||
- It goes to the backend in prompts
|
||||
- It gets an ergonomic `.value` accessor (like Vue's `defineModel()`)
|
||||
|
||||
This mirrors Vue's `modelValue` — the prop that `v-model` binds to.
|
||||
|
||||
### API Surface
|
||||
|
||||
```typescript
|
||||
interface WidgetHandle<T> {
|
||||
// Schema (readonly)
|
||||
readonly name: string
|
||||
readonly widgetType: string
|
||||
|
||||
// Props: value (modelValue) — ergonomic accessor
|
||||
value: T
|
||||
getValue(): T // alias
|
||||
setValue(v: T): void // alias
|
||||
|
||||
// Props: common — ergonomic accessors
|
||||
isHidden(): boolean
|
||||
setHidden(hidden: boolean): void
|
||||
isDisabled(): boolean
|
||||
setDisabled(disabled: boolean): void
|
||||
|
||||
// Props: type-specific — via getOption/setOption
|
||||
getOption<K>(key: string): K | undefined
|
||||
setOption(key: string, value: unknown): void
|
||||
}
|
||||
```
|
||||
|
||||
### ECS Mapping
|
||||
|
||||
The `WidgetHandle` facade maps to ECS components:
|
||||
|
||||
| WidgetHandle | ECS Component |
|
||||
| ----------------------------- | ------------------------------- |
|
||||
| `name`, `widgetType` | `WidgetComponentSchema` |
|
||||
| `value` | `WidgetComponentValue` |
|
||||
| `hidden`, `disabled`, `label` | `WidgetComponentDisplay` |
|
||||
| `serialize` | `WidgetComponentSerialize` |
|
||||
| type-specific options | `WidgetComponentSchema.options` |
|
||||
|
||||
The 5-component split is an implementation detail. Extensions see only Schema + Props.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Simple mental model: just two categories (Schema + Props)
|
||||
- Aligns with Vue's component model (props, modelValue, one-way data flow)
|
||||
- Clear rule: "presence is schema, values are props"
|
||||
- ECS internals hidden behind facade
|
||||
- `.value` accessor provides ergonomic access to the primary data
|
||||
- Functions treated as values (JS-native thinking)
|
||||
|
||||
### Negative
|
||||
|
||||
- Existing code uses mixed patterns (`widget.hidden` vs `widget.options.hidden`) — migration needed
|
||||
- The "presence vs value" distinction may be confusing initially
|
||||
- `getOption`/`setOption` is less ergonomic than direct property access for common props
|
||||
|
||||
### Migration
|
||||
|
||||
For extensions currently using `widget.options.hidden = true`:
|
||||
|
||||
1. Phase A: Shim translates to internal mutation
|
||||
2. Phase B: `setHidden()` dispatches ECS command (enables undo/redo)
|
||||
3. Deprecation warnings guide to `widget.setHidden(true)` or `widget.setProp('hidden', true)`
|
||||
|
||||
## Notes
|
||||
|
||||
### Slack Discussion (2026-05-12)
|
||||
|
||||
Key insights from `#frontend-eng`:
|
||||
|
||||
- Austin: "Using min as an example. Under what circumstances would it change, or need to be externally observable?"
|
||||
- Alex: "A lot of bugs come from 'changing the graph topology mutates values'"
|
||||
- Christian: "The presence of min and max are immutable in the schema. Along with defaults. Their values would be props, which are only set by the systems"
|
||||
- Christian: "Views of the data shouldn't directly mutate the props just like with Vue"
|
||||
|
||||
### Related Decisions
|
||||
|
||||
- D7: Widget shape and persistence model (superseded by this ADR for categorization)
|
||||
- D13: ECS alignment audit (identified the dual `hidden` bug)
|
||||
- D14: Decision log entry for this ADR
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. How does this interact with Node Definition V3's `V3.CustomWidget`?
|
||||
2. Schema merging for subgraph widgets with mixed constraints
|
||||
3. Should connecting a second widget to a subgraph widget reset to default?
|
||||
111
docs/adr/0011-immutability-via-fresh-copies.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 11. Immutability Enforcement via Fresh Copies
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The extension API exposes collection-returning methods like `widgets()`, `inputs()`, `outputs()`, and object-returning methods like `getProperties()`. These methods need immutability guarantees to prevent extensions from accidentally or intentionally mutating internal state.
|
||||
|
||||
### The Problem
|
||||
|
||||
Without runtime immutability enforcement:
|
||||
|
||||
- Extensions could push items into `widgets()` array, corrupting internal state
|
||||
- Mutations to returned objects would silently affect internal data
|
||||
- Debugging would be difficult — state corruption could surface far from the mutation site
|
||||
- Internal framework code might inadvertently rely on returned arrays being stable
|
||||
|
||||
TypeScript's `readonly` modifier and JSDoc annotations provide compile-time protection, but:
|
||||
|
||||
- JavaScript consumers have no protection
|
||||
- Type assertions can bypass readonly
|
||||
- Agent-generated code may not respect type hints
|
||||
|
||||
### Options Considered
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| **1. `Object.freeze()`** | Runtime immutability, throws on mutation | Performance overhead, nested objects need deep freeze |
|
||||
| **2. Return fresh copy** | Simple, functional style, no mutation affects source | Slight memory overhead, multiple calls = multiple arrays |
|
||||
| **3. Proxy wrapper** | Helpful error messages, can intercept specific operations | Complexity, performance overhead, harder to debug |
|
||||
| **4. TypeScript only** | Zero runtime cost | No protection for JS consumers, can be bypassed |
|
||||
| **5. Private fields** | True encapsulation | Blocks read access too, not suitable for APIs |
|
||||
|
||||
## Decision
|
||||
|
||||
**Return fresh copies** (Option 2) for all collection-returning and object-returning methods in the extension API.
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
```ts
|
||||
// CORRECT: Return fresh copy
|
||||
widgets(): readonly WidgetHandle[] {
|
||||
const container = world.getComponent(nodeId, WidgetComponentContainer)
|
||||
return (container?.widgetIds ?? []).map(createWidgetHandle)
|
||||
// Each call creates new array — mutations don't affect internal state
|
||||
}
|
||||
|
||||
getProperties(): Record<string, unknown> {
|
||||
return { ...world.getComponent(nodeId, NodeTypeKey)?.properties }
|
||||
// Shallow copy — mutations don't affect source
|
||||
}
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
Apply this pattern to:
|
||||
|
||||
- `NodeHandle.widgets()` — returns fresh `WidgetHandle[]`
|
||||
- `NodeHandle.inputs()` — returns fresh `SlotInfo[]`
|
||||
- `NodeHandle.outputs()` — returns fresh `SlotInfo[]`
|
||||
- `NodeHandle.getProperties()` — returns fresh `Record<string, unknown>`
|
||||
- `WidgetHandle` methods that return objects (if any)
|
||||
- Any future collection/object-returning methods
|
||||
|
||||
### Internal Callers
|
||||
|
||||
Framework-internal code must also use mutation APIs rather than mutating returned collections:
|
||||
|
||||
```ts
|
||||
// WRONG: Mutating returned array
|
||||
const widgets = node.widgets()
|
||||
widgets.push(newWidget) // No effect on node!
|
||||
|
||||
// CORRECT: Use mutation API
|
||||
node.addWidget(type, name, value, options)
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **True immutability**: Mutations to returned data never affect internal state
|
||||
- **Predictable behavior**: Each call returns fresh data reflecting current state
|
||||
- **Simple mental model**: "This is your copy, do what you want with it"
|
||||
- **JavaScript-safe**: Works regardless of TypeScript types
|
||||
|
||||
### Negative
|
||||
|
||||
- **Memory overhead**: Multiple calls create multiple arrays (usually negligible)
|
||||
- **No mutation detection**: Extensions silently get isolated copies, won't know their mutations are ignored
|
||||
- **Fresh reference each call**: Cannot use `===` to detect changes (use deep comparison or events)
|
||||
|
||||
### Mitigations
|
||||
|
||||
- Document that returned collections are snapshots
|
||||
- Use events (`valueChange`, `propertyChange`) to observe changes
|
||||
- The memory overhead is negligible for typical widget/slot counts
|
||||
|
||||
## Notes
|
||||
|
||||
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 14 for the full discussion thread.
|
||||
|
||||
The alternative of `Object.freeze()` was rejected because:
|
||||
|
||||
- It requires deep freezing for nested objects
|
||||
- Performance overhead for each call
|
||||
- Fresh copies achieve the same goal more simply
|
||||
138
docs/adr/0012-pure-function-loader-pattern.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 12. Pure Function Loader Pattern for Extension Registration
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The v2 extension API needs a mechanism for extensions to register themselves with the runtime. Two broad approaches exist:
|
||||
|
||||
### Side-Effect Registration (Vue 2 Plugin Pattern)
|
||||
|
||||
```ts
|
||||
// Extension self-registers at import time
|
||||
import { app } from '@comfyorg/core'
|
||||
|
||||
app.use({
|
||||
install(app) {
|
||||
app.component('MyWidget', MyWidget)
|
||||
app.directive('my-directive', myDirective)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Problems:
|
||||
|
||||
- **Import order matters**: If extension A depends on extension B being registered first, import order must be carefully managed
|
||||
- **Hard to test**: Side effects at import time make mocking difficult; tests must manipulate module cache
|
||||
- **Hard to tree-shake**: Bundlers can't eliminate unused extensions — the import executes
|
||||
- **Timing coupling**: Registration and activation are conflated; can't collect extensions first, then activate later
|
||||
|
||||
### Pure Function + Loader Pattern
|
||||
|
||||
```ts
|
||||
// Extension declares intent — no side effects
|
||||
export default defineNode({
|
||||
name: 'my-extension',
|
||||
nodeTypes: ['MyNode'],
|
||||
nodeCreated(handle) {
|
||||
// ...
|
||||
}
|
||||
})
|
||||
|
||||
// App bootstrap activates all registered extensions
|
||||
startExtensionSystem()
|
||||
```
|
||||
|
||||
## Decision
|
||||
|
||||
**Adopt the pure function + loader pattern** for v2 extension registration.
|
||||
|
||||
### Implementation
|
||||
|
||||
```ts
|
||||
// Extension Registry (data collection only)
|
||||
const nodeExtensions: NodeExtensionOptions[] = []
|
||||
|
||||
export function defineNode(options: NodeExtensionOptions): void {
|
||||
nodeExtensions.push(options)
|
||||
}
|
||||
|
||||
// Loader (activation)
|
||||
export function startExtensionSystem(): void {
|
||||
const world = getWorld()
|
||||
watch(
|
||||
() => world.entitiesWith(NodeTypeKey),
|
||||
(nodeEntityIds) => {
|
||||
for (const id of nodeEntityIds) {
|
||||
mountExtensionsForNode(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
|
||||
1. **Pure registration**: `defineNode()` has no side effects beyond pushing to an array. It doesn't touch the World, DOM, or any reactive state.
|
||||
|
||||
2. **Centralized activation**: `startExtensionSystem()` is called exactly once during app bootstrap. This single entry point controls when the extension system "goes live".
|
||||
|
||||
3. **Reactive mounting**: The loader watches the World for entity changes. Extensions are mounted/unmounted in response to ECS state, not imperative calls.
|
||||
|
||||
4. **Order independence**: Extensions can be defined in any order. The loader sorts by name (lexicographic, see D10b) for deterministic execution.
|
||||
|
||||
### Registration Flow
|
||||
|
||||
```
|
||||
Extension files App bootstrap World
|
||||
| | |
|
||||
| defineNode({...}) | |
|
||||
|--------------------->| |
|
||||
| (push to array) | |
|
||||
| | |
|
||||
| | startExtensionSystem()
|
||||
| |------------------>|
|
||||
| | (watch for NodeType entities)
|
||||
| | |
|
||||
| | NodeType added |
|
||||
| |<------------------|
|
||||
| | |
|
||||
| | mountExtensionsForNode(id)
|
||||
| | (runs setup) |
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Testability**: Extensions are plain objects; tests can construct them without side effects. `_clearExtensionsForTesting()` resets state between tests.
|
||||
- **Tree-shakeable**: Bundlers can eliminate unused extension files if their exports are never referenced.
|
||||
- **Order independent**: No import order bugs — the loader handles activation order.
|
||||
- **Lazy activation**: Registration is instant; activation only happens when `startExtensionSystem()` is called.
|
||||
- **SSR friendly**: Pure functions don't execute browser-only code at import time.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Manual bootstrap**: App must call `startExtensionSystem()` — forgetting it silently disables extensions.
|
||||
- **Two-step mental model**: Developers must understand "register" vs "activate" phases.
|
||||
|
||||
### Mitigations
|
||||
|
||||
- App bootstrap is a well-defined location; the call is hard to miss.
|
||||
- Clear documentation and starter templates include the bootstrap call.
|
||||
- Dev-mode warnings if extensions are defined but the system never starts.
|
||||
|
||||
## Notes
|
||||
|
||||
This pattern aligns with modern framework conventions:
|
||||
|
||||
- **Vite plugins**: `vite.config.ts` collects plugins as an array; Vite activates them at build time.
|
||||
- **Vue 3 Composition API**: `setup()` returns reactive state; the framework activates it.
|
||||
- **React hooks**: Pure functions declare effects; React schedules them.
|
||||
|
||||
The key insight is separating **declaration** (what do I want?) from **execution** (make it happen). This separation enables testing, lazy loading, and predictable behavior.
|
||||
@@ -8,16 +8,19 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
|
||||
## ADR Index
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| ADR | Title | Status | Date |
|
||||
| ---------------------------------------------------------- | ------------------------------------------ | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0010](0010-deprecate-node-level-serialization-control.md) | Deprecate Node-Level Serialization Control | Accepted | 2026-05-12 |
|
||||
| [0011](0011-immutability-via-fresh-copies.md) | Immutability Enforcement via Fresh Copies | Accepted | 2026-05-12 |
|
||||
| [0012](0012-pure-function-loader-pattern.md) | Pure Function Loader Pattern | Accepted | 2026-05-12 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
93
docs/research/coordinate-systems.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Research: Canvas vs Client/Pixel Coordinate Usage
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
How should the extension API handle coordinate systems? Should it expose canvas coordinates, screen/client coordinates, or both?
|
||||
|
||||
## Coordinate Systems in ComfyUI
|
||||
|
||||
### 1. Canvas Space (Logical Units)
|
||||
|
||||
Node positions and sizes are in canvas logical units:
|
||||
|
||||
- Independent of zoom/pan
|
||||
- `[0, 0]` is the canvas origin
|
||||
- Moving a node to `[100, 200]` places it at canvas position (100, 200) regardless of viewport state
|
||||
|
||||
### 2. Screen/Client Space (Pixels)
|
||||
|
||||
DOM elements use pixel coordinates relative to the viewport:
|
||||
|
||||
- Affected by zoom/pan/scroll
|
||||
- `clientX`/`clientY` from mouse events
|
||||
- `getBoundingClientRect()` returns pixel values
|
||||
|
||||
### 3. Widget Height (Pixels)
|
||||
|
||||
DOM widgets reserve height in pixels:
|
||||
|
||||
```ts
|
||||
addDOMWidget({ name: 'preview', element: img, height: 200 }) // 200px
|
||||
```
|
||||
|
||||
## Current Extension API
|
||||
|
||||
| Method | Coordinate System | Notes |
|
||||
| -------------------------- | ----------------- | ----------------------------------------- |
|
||||
| `getPosition()` | Canvas | Returns `[x, y]` in canvas units |
|
||||
| `setPosition()` | Canvas | Accepts `[x, y]` in canvas units |
|
||||
| `getSize()` | Canvas | Returns `[width, height]` in canvas units |
|
||||
| `setSize()` | Canvas | Accepts `[width, height]` in canvas units |
|
||||
| `addDOMWidget({ height })` | Pixels | Reserved height in pixels |
|
||||
| `widget.setHeight(px)` | Pixels | Widget height in pixels |
|
||||
|
||||
## Analysis
|
||||
|
||||
### When Extensions Need Canvas Coordinates
|
||||
|
||||
1. **Node positioning**: Placing nodes relative to each other
|
||||
2. **Layout algorithms**: Auto-arranging nodes in a pattern
|
||||
3. **Collision detection**: Checking if nodes overlap
|
||||
|
||||
### When Extensions Need Screen Coordinates
|
||||
|
||||
1. **Custom overlays**: Drawing UI at a specific screen location
|
||||
2. **Drag-and-drop from external sources**: Converting mouse position to canvas position
|
||||
3. **Context menus**: Positioning menus near the cursor
|
||||
|
||||
### Current State
|
||||
|
||||
The extension API currently exposes:
|
||||
|
||||
- **Canvas coordinates** for node position/size — appropriate, as these are logical values
|
||||
- **Pixel values** for DOM widget height — appropriate, as these are DOM measurements
|
||||
|
||||
**Missing**: No conversion helpers between canvas and screen coordinates.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**The current approach is appropriate.** Extensions that manipulate node positions should work in canvas space. This is the natural abstraction — extensions shouldn't need to account for zoom/pan when laying out nodes.
|
||||
|
||||
### For Advanced Cases
|
||||
|
||||
Extensions needing coordinate conversion (e.g., custom overlays) should either:
|
||||
|
||||
1. **Use LiteGraph's existing transform utilities** (available on `app.canvas`)
|
||||
2. **Access the transform state** via a future canvas API (not part of node/widget handles)
|
||||
|
||||
### Why Not Expose Conversion Helpers on NodeHandle?
|
||||
|
||||
- **Wrong abstraction level**: Coordinate conversion is a canvas concern, not a node concern
|
||||
- **State dependency**: Conversion requires current zoom/pan state, which changes frequently
|
||||
- **Rare use case**: Most extensions work entirely in canvas space
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If multiple extensions need coordinate conversion, consider:
|
||||
|
||||
1. **Canvas API**: `canvas.screenToCanvas(point)` / `canvas.canvasToScreen(point)`
|
||||
2. **Events with both coordinates**: `positionChanged` could include both canvas and screen positions
|
||||
|
||||
For now, no changes are needed — the current API serves the common cases well.
|
||||
93
docs/research/dom-widget-convergence.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Research: DOM Widget Convergence with Base Widget
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
Should DOM widgets be unified with base widgets, or kept as a separate concept?
|
||||
|
||||
## Current State
|
||||
|
||||
### Creation APIs
|
||||
|
||||
- `node.addWidget(type, name, value, options)` — creates a standard widget
|
||||
- `node.addDOMWidget({ name, element, height })` — creates a DOM-backed widget
|
||||
|
||||
### Internal Implementation
|
||||
|
||||
Both use the same underlying `CreateWidget` command:
|
||||
|
||||
```ts
|
||||
addWidget(type, name, defaultValue, options) {
|
||||
return dispatch({ type: 'CreateWidget', widgetType: type, ... })
|
||||
}
|
||||
|
||||
addDOMWidget(opts) {
|
||||
return dispatch({ type: 'CreateWidget', widgetType: 'DOM', ... })
|
||||
}
|
||||
```
|
||||
|
||||
DOM widgets are just widgets with `widgetType: 'DOM'` and an element reference.
|
||||
|
||||
### Shared WidgetHandle Interface
|
||||
|
||||
Both widget types share the same `WidgetHandle` interface:
|
||||
|
||||
| Method | Standard Widget | DOM Widget |
|
||||
| -------------------------------- | --------------- | ----------------------- |
|
||||
| `entityId`, `name`, `widgetType` | ✓ | ✓ |
|
||||
| `getValue()` / `setValue()` | ✓ (scalar) | ✓ (often unused) |
|
||||
| `isHidden()` / `setHidden()` | ✓ | ✓ |
|
||||
| `isDisabled()` / `setDisabled()` | ✓ | ✓ |
|
||||
| `setHeight(px)` | no-op | ✓ (updates reservation) |
|
||||
| `on('valueChange')` | ✓ | ✓ |
|
||||
| `getOption()` / `setOption()` | ✓ | ✓ |
|
||||
|
||||
## Analysis
|
||||
|
||||
### Arguments FOR Full Convergence
|
||||
|
||||
1. **Single mental model**: Extensions learn one widget concept, not two.
|
||||
2. **Consistent behavior**: All widgets appear in `node.widgets()`, serialize the same way.
|
||||
3. **Simpler API surface**: Fewer methods to document and maintain.
|
||||
|
||||
### Arguments FOR Keeping Separate APIs
|
||||
|
||||
1. **Different ergonomics**: Standard widgets are data-driven (name, value, options); DOM widgets are element-driven (pass an HTMLElement).
|
||||
2. **Type safety**: `addDOMWidget` can require `element: HTMLElement` at compile time; merging would make it optional with runtime checks.
|
||||
3. **Clear intent**: Separate APIs signal different use cases.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep the current partial convergence.** The implementation is unified (`CreateWidget` command), but the creation APIs remain separate for ergonomic reasons.
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Creation differs, usage is unified.** Extensions create DOM widgets differently (need an element), but interact with them the same way (via `WidgetHandle`).
|
||||
|
||||
2. **Type safety is valuable.** `addDOMWidget({ element })` is clearer than `addWidget('DOM', name, null, { element })`.
|
||||
|
||||
3. **Already well-integrated.** DOM widgets appear in `node.widgets()`, get the same events, and use the same serialization infrastructure.
|
||||
|
||||
### What "Convergence" Means Here
|
||||
|
||||
The widgets are already converged at:
|
||||
|
||||
- **Entity level**: Same `WidgetEntityId` brand
|
||||
- **Interface level**: Same `WidgetHandle` type
|
||||
- **Command level**: Same `CreateWidget` command internally
|
||||
|
||||
The APIs are intentionally separate at:
|
||||
|
||||
- **Creation level**: `addWidget` vs `addDOMWidget`
|
||||
|
||||
This is the right split — unified where it matters (runtime behavior), separate where it improves DX (creation ergonomics).
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If we add more widget creation patterns (e.g., `addCanvasWidget`, `addThreeJSWidget`), we might consider:
|
||||
|
||||
1. **Factory pattern**: `node.widgets.create('DOM', { element })` / `node.widgets.create('INT', { min, max })`
|
||||
2. **Builder pattern**: `node.addWidget('DOM').withElement(el).withHeight(200).build()`
|
||||
|
||||
For now, two explicit methods (`addWidget`, `addDOMWidget`) serve the common cases well.
|
||||
112
docs/research/identity-encapsulation.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Research: Identity Encapsulation in the Extension API
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
When do extensions need access to raw entity IDs (`NodeEntityId`, `WidgetEntityId`, `SlotEntityId`)? Should these be exposed or hidden?
|
||||
|
||||
## Current State
|
||||
|
||||
The v2 extension API exposes entity IDs as read-only properties:
|
||||
|
||||
```ts
|
||||
interface NodeHandle {
|
||||
readonly entityId: NodeEntityId
|
||||
// ...
|
||||
}
|
||||
|
||||
interface WidgetHandle {
|
||||
readonly entityId: WidgetEntityId
|
||||
// ...
|
||||
}
|
||||
|
||||
interface SlotInfo {
|
||||
readonly entityId: SlotEntityId
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
All IDs are **branded types** to prevent accidental mixing at compile time.
|
||||
|
||||
## Use Cases for Raw Entity IDs
|
||||
|
||||
### 1. Per-Instance State Mapping
|
||||
|
||||
Extensions maintaining external state per node:
|
||||
|
||||
```ts
|
||||
const nodeCache = new Map<NodeEntityId, CachedData>()
|
||||
|
||||
defineNode({
|
||||
name: 'my-cache-extension',
|
||||
nodeCreated(handle) {
|
||||
nodeCache.set(handle.entityId, computeExpensiveData())
|
||||
onNodeRemoved(() => nodeCache.delete(handle.entityId))
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Logging and Debugging
|
||||
|
||||
```ts
|
||||
node.on('executed', (e) => {
|
||||
console.log(`[${node.entityId}] Output:`, e.output)
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Inter-Extension Communication
|
||||
|
||||
Extensions that need to coordinate across multiple nodes:
|
||||
|
||||
```ts
|
||||
// Extension A stores data
|
||||
globalState.set(nodeA.entityId, data)
|
||||
|
||||
// Extension B retrieves it
|
||||
const data = globalState.get(nodeB.entityId)
|
||||
```
|
||||
|
||||
### 4. External System Interop
|
||||
|
||||
Extensions integrating with analytics, debugging tools, or external services that need stable node identifiers.
|
||||
|
||||
## Analysis
|
||||
|
||||
### Arguments FOR Exposing Entity IDs
|
||||
|
||||
1. **Legitimate need exists** — The use cases above are real and common.
|
||||
2. **Branded types prevent misuse** — Can't accidentally use `NodeEntityId` where `WidgetEntityId` is expected.
|
||||
3. **Read-only access** — Extensions can't mutate the ID or corrupt internal state.
|
||||
4. **Opaque value** — The format (`node:<graphUuid>:<localId>`) is an implementation detail; extensions should treat it as an opaque string.
|
||||
|
||||
### Arguments AGAINST Exposing Entity IDs
|
||||
|
||||
1. **Format coupling** — Extensions might parse the ID string and break if format changes.
|
||||
2. **Internal detail leakage** — Knowing the ID scheme reveals ECS architecture.
|
||||
3. **Future migration friction** — Changing ID representation requires careful deprecation.
|
||||
|
||||
### Mitigations
|
||||
|
||||
- **Document as opaque**: JSDoc clearly states IDs are opaque, not to be parsed.
|
||||
- **Branded types**: TypeScript prevents misuse across entity categories.
|
||||
- **Phase A format**: Current format includes graph UUID + local ID; this can evolve via semver.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep exposing entity IDs.** The use cases are legitimate, the branded types provide safety, and the read-only nature limits risk. Document that IDs are opaque strings — extensions should never parse or construct them.
|
||||
|
||||
### Guidelines for Extension Authors
|
||||
|
||||
1. **Use IDs only for keying** — Maps, Sets, logging, external system references.
|
||||
2. **Never parse IDs** — The format is an implementation detail subject to change.
|
||||
3. **Prefer handles over IDs** — When passing references between functions, use the handle object, not the raw ID.
|
||||
4. **Clean up on removal** — Always use `onNodeRemoved()` to clean up Maps keyed by entityId.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If the ID format needs to change significantly, the branded types allow us to:
|
||||
|
||||
1. Introduce a new branded type (e.g., `NodeEntityIdV2`)
|
||||
2. Deprecate the old ID with migration guidance
|
||||
3. Keep both supported during a transition period
|
||||
121
docs/research/serialization-context.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Research: Serialization Context Simplification
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
Can the serialization context be simplified from 4 values to fewer?
|
||||
|
||||
Current contexts:
|
||||
|
||||
- `'workflow'` — saving workflow to disk
|
||||
- `'prompt'` — queueing a run (API call)
|
||||
- `'clone'` — copy/paste operation
|
||||
- `'subgraph-promote'` — widget becoming subgraph IO
|
||||
|
||||
## Use Case Analysis
|
||||
|
||||
### Context: 'workflow'
|
||||
|
||||
**Purpose**: Full persistence of user's work.
|
||||
|
||||
**What extensions need**: Serialize everything the user configured.
|
||||
|
||||
**Example**: A widget storing user preferences needs to include all settings.
|
||||
|
||||
### Context: 'prompt'
|
||||
|
||||
**Purpose**: Sending data to the backend for execution.
|
||||
|
||||
**What extensions need**:
|
||||
|
||||
- Transform values (dynamic prompts → resolved text)
|
||||
- Skip preview-only widgets
|
||||
- Materialize async sources (webcam → frame data)
|
||||
|
||||
**Example**:
|
||||
|
||||
```ts
|
||||
widget.on('beforeSerialize', async (e) => {
|
||||
if (e.context === 'prompt') {
|
||||
e.setSerializedValue(await captureFrame())
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Context: 'clone'
|
||||
|
||||
**Purpose**: Copy/paste should yield independent copy.
|
||||
|
||||
**What extensions need**: Reset instance-specific state while keeping user settings.
|
||||
|
||||
**Example**: A random seed widget might want a new seed on paste.
|
||||
|
||||
### Context: 'subgraph-promote'
|
||||
|
||||
**Purpose**: Widget becomes an input/output on a subgraph.
|
||||
|
||||
**What extensions need**: Convert internal representation to subgraph IO format.
|
||||
|
||||
**Example**: Internal state becomes an exposed parameter.
|
||||
|
||||
## Simplification Options
|
||||
|
||||
### Option A: Keep All 4 (Current State)
|
||||
|
||||
| Pro | Con |
|
||||
| ---------------------------------------- | ----------------- |
|
||||
| Each context has distinct semantics | 4 cases to handle |
|
||||
| Type system enforces valid values | More complex API |
|
||||
| Clear intent for each serialization path | |
|
||||
|
||||
### Option B: Collapse to 2 ('persist' | 'execute')
|
||||
|
||||
```ts
|
||||
context: 'persist' | 'execute'
|
||||
// 'persist' = workflow, clone, subgraph-promote
|
||||
// 'execute' = prompt
|
||||
```
|
||||
|
||||
| Pro | Con |
|
||||
| ------------------------------------------ | ------------------------------- |
|
||||
| Simpler mental model | Loses clone/promote distinction |
|
||||
| Most extensions only care about this split | Can't reset seed on clone |
|
||||
|
||||
### Option C: Remove Context Entirely
|
||||
|
||||
Extensions always transform regardless of context. The framework handles differences.
|
||||
|
||||
| Pro | Con |
|
||||
| ---------------------------- | ---------------------------------------------- |
|
||||
| Simplest API | Loses control for edge cases |
|
||||
| Framework handles all nuance | Some extensions need context-specific behavior |
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep all 4 contexts.** The use cases are genuinely different:
|
||||
|
||||
1. **workflow vs prompt**: Very common distinction. Dynamic prompts only process on prompt; preview widgets skip prompt. This is the most important split.
|
||||
|
||||
2. **clone**: Less common, but needed for stateful widgets (random seeds, generated IDs, captured frames).
|
||||
|
||||
3. **subgraph-promote**: Specialized, but necessary for the subgraph feature to work correctly.
|
||||
|
||||
### Rationale
|
||||
|
||||
- Extensions that don't care can ignore the context.
|
||||
- Extensions that do care have the information they need.
|
||||
- The 4 values map to 4 distinct operations in the framework.
|
||||
- Collapsing contexts would remove functionality with no real simplification gain.
|
||||
|
||||
### Mitigation for Complexity
|
||||
|
||||
- Document common patterns clearly
|
||||
- Most extensions only need: `if (context === 'prompt')`
|
||||
- Provide examples in JSDoc
|
||||
|
||||
## Note on Deprecation
|
||||
|
||||
The `NodeBeforeSerializeEvent` is deprecated (ADR-0010). The `WidgetBeforeSerializeEvent` remains supported and uses the same 4 contexts.
|
||||
|
||||
Since node-level serialization is being removed, this research applies to widget-level serialization only.
|
||||
148
docs/research/widget-state-categories.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Widget State Categories
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Overview
|
||||
|
||||
Widget state in the v2 extension API is organized into distinct categories, each with different characteristics for mutability, persistence, and event handling.
|
||||
|
||||
## Categories
|
||||
|
||||
### 1. Identity (Read-Only Invariants)
|
||||
|
||||
Set at construction, never change.
|
||||
|
||||
| Property | Type | Notes |
|
||||
| ------------ | ---------------- | ------------------------------------ |
|
||||
| `entityId` | `WidgetEntityId` | Branded, stable for widget lifetime |
|
||||
| `name` | `string` | Widget name as registered |
|
||||
| `widgetType` | `string` | e.g., `'INT'`, `'STRING'`, `'COMBO'` |
|
||||
| `label` | `string` | Display label, defaults to `name` |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- No setters exist for these properties
|
||||
- Extensions cannot modify identity after creation
|
||||
- Attempting to change identity is a design error
|
||||
|
||||
### 2. Value (First-Class, Every Widget)
|
||||
|
||||
The primary user-edited data.
|
||||
|
||||
| Method | Notes |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| `getValue()` | Returns current value |
|
||||
| `setValue(v)` | Dispatches `SetWidgetValue` command |
|
||||
| `on('valueChange')` | Fires on value mutation |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Type varies by widget type (`number` for INT, `string` for STRING, etc.)
|
||||
- Persisted to `widgets_values` in workflow JSON
|
||||
- Included in API prompt by default (unless `setSerializeEnabled(false)`)
|
||||
- Changes are undo-able via command dispatch
|
||||
|
||||
### 3. Properties (First-Class, Every Widget)
|
||||
|
||||
Common properties all widgets share.
|
||||
|
||||
| Property | Getter | Setter | Event |
|
||||
| ----------- | ---------------------- | ------------------------ | ---------------- |
|
||||
| `hidden` | `isHidden()` | `setHidden(b)` | `propertyChange` |
|
||||
| `disabled` | `isDisabled()` | `setDisabled(b)` | `propertyChange` |
|
||||
| `serialize` | `isSerializeEnabled()` | `setSerializeEnabled(b)` | `propertyChange` |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Boolean values only
|
||||
- `hidden` affects UI visibility, not serialization
|
||||
- `disabled` makes widget read-only in UI
|
||||
- `serialize` controls inclusion in workflow/prompt output
|
||||
- Changes fire `propertyChange`, not `valueChange`
|
||||
|
||||
### 4. Options Bag (Type-Specific)
|
||||
|
||||
Per-instance overrides for type-specific configuration.
|
||||
|
||||
| Method | Notes |
|
||||
| ----------------------- | ---------------------------------------------- |
|
||||
| `getOption(key)` | Returns per-instance override or class default |
|
||||
| `setOption(key, value)` | Persists to `widget_options` sidecar |
|
||||
| `on('optionChange')` | Fires on option mutation |
|
||||
|
||||
**Common options by widget type:**
|
||||
|
||||
| Widget Type | Options |
|
||||
| ----------- | ---------------------------------- |
|
||||
| INT, FLOAT | `min`, `max`, `step`, `precision` |
|
||||
| STRING | `multiline`, `placeholder`, `rows` |
|
||||
| COMBO | `values` |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Options are JSON-serializable values
|
||||
- Persisted separately from `widgets_values` (additive, backward-compatible)
|
||||
- Extensions can add custom options
|
||||
- Option keys should be documented per widget type
|
||||
|
||||
### 5. DOM-Specific
|
||||
|
||||
Properties unique to DOM widgets.
|
||||
|
||||
| Method | Notes |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| `setHeight(px)` | Updates reserved height, triggers relayout |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Only meaningful for `addDOMWidget()` widgets
|
||||
- No-op for non-DOM widgets
|
||||
- Measured in pixels (screen space)
|
||||
- No event fired; relayout is automatic
|
||||
|
||||
## Category Interaction Rules
|
||||
|
||||
### Event Separation
|
||||
|
||||
Each category has its own event:
|
||||
|
||||
| Category | Event |
|
||||
| ---------- | ---------------- |
|
||||
| Value | `valueChange` |
|
||||
| Properties | `propertyChange` |
|
||||
| Options | `optionChange` |
|
||||
|
||||
**Rule**: Events do not cross categories. Changing `hidden` does not fire `valueChange`.
|
||||
|
||||
### Serialization Behavior
|
||||
|
||||
| Category | Serialization |
|
||||
| ---------- | ---------------------------------------------------------------- |
|
||||
| Identity | Not serialized (derived from node type) |
|
||||
| Value | `widgets_values` array |
|
||||
| Properties | `hidden`/`disabled` not persisted; `serialize` affects inclusion |
|
||||
| Options | `widget_options` sidecar object |
|
||||
|
||||
### Mutability Summary
|
||||
|
||||
| Category | Mutable | Undo-able | Fires Event |
|
||||
| ---------- | ------- | --------- | ---------------- |
|
||||
| Identity | ✗ | — | — |
|
||||
| Value | ✓ | ✓ | `valueChange` |
|
||||
| Properties | ✓ | ✓ | `propertyChange` |
|
||||
| Options | ✓ | ✓ | `optionChange` |
|
||||
| DOM Height | ✓ | ✗ | — |
|
||||
|
||||
## Agent Implementation Notes
|
||||
|
||||
Agents working with widget state should:
|
||||
|
||||
1. **Respect category boundaries**: Don't try to `setValue()` to change visibility; use `setHidden()`.
|
||||
|
||||
2. **Use appropriate events**: Listen to `propertyChange` for UI state, `valueChange` for data.
|
||||
|
||||
3. **Handle type-specific options carefully**: Check widget type before accessing type-specific options.
|
||||
|
||||
4. **Preserve identity invariants**: Never try to change `entityId`, `name`, `widgetType`, or `label`.
|
||||
|
||||
5. **Consider serialization context**: Options persist to a sidecar; values persist to the main array.
|
||||
@@ -82,6 +82,7 @@ export default defineConfig([
|
||||
'components.d.ts',
|
||||
'coverage/*',
|
||||
'dist/*',
|
||||
'packages/extension-api/api-snapshot/**',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'playwright-report/*',
|
||||
'src/extensions/core/*',
|
||||
@@ -103,7 +104,10 @@ export default defineConfig([
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts'
|
||||
'vite.types.config.mts',
|
||||
'packages/extension-api/scripts/build-docs.ts',
|
||||
'packages/extension-api/vite.config.mts',
|
||||
'vitest.extension-api.config.mts'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
treatConfigHintsAsErrors: true,
|
||||
// I-TF (#12145): the test framework references symbols that foundation
|
||||
// tags with @publicAPI (e.g. `_setDispatchImplForTesting`,
|
||||
// `NodeExtensionOptions`). With tests present those tags become
|
||||
// "redundant" hints. They are still correct on foundation alone, so
|
||||
// we keep the tag definition and just downgrade hint→warning here.
|
||||
treatConfigHintsAsErrors: false,
|
||||
workspaces: {
|
||||
'.': {
|
||||
entry: [
|
||||
@@ -9,6 +14,10 @@ const config: KnipConfig = {
|
||||
'src/assets/css/style.css',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts',
|
||||
// Public extension API surface — published package entry point.
|
||||
// Per AGENTS.md, this barrel is the explicit exception to the
|
||||
// no-barrel-files-in-src rule because it IS the package entry.
|
||||
'src/extension-api/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
|
||||
@@ -32,6 +41,10 @@ const config: KnipConfig = {
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/extension-api': {
|
||||
// Build output is committed for npm package visibility
|
||||
ignore: ['build/**']
|
||||
},
|
||||
'apps/website': {
|
||||
entry: ['src/scripts/**/*.ts']
|
||||
}
|
||||
@@ -60,13 +73,48 @@ const config: KnipConfig = {
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Devtools extensions, included dynamically
|
||||
'tools/devtools/web/**'
|
||||
'tools/devtools/web/**',
|
||||
// Deprecated stub re-exporting from `@/extension-api`. Will be removed
|
||||
// once PKG2 (`@comfyorg/extension-api`) ships and downstream imports
|
||||
// migrate to the package path.
|
||||
'src/types/extensionV2.ts',
|
||||
// D18 Phase 1 scaffolding — empty registries the loader will populate
|
||||
// in Phase 2 once side-effect registration moves out of
|
||||
// extension-api-service. See decisions/D18-pure-functions-loader-registration.md.
|
||||
'src/services/registries/**',
|
||||
// D18 Phase 1 — brand symbol + isBrandedExtension guard. Currently
|
||||
// consumed only by the define* call sites inside extension-api-service;
|
||||
// the type-guard and getBrandKind are exported for the Phase 2 loader.
|
||||
'src/extension-api/brand.ts',
|
||||
// Strangler-pattern v2 conversions of core extensions. Not yet wired
|
||||
// into the bootstrap (registration lands in a follow-up PR alongside
|
||||
// the v1→v2 cut-over). Tracked by I-EXT (#12144).
|
||||
'src/extensions/core/noteNode.v2.ts',
|
||||
'src/extensions/core/rerouteNode.v2.ts',
|
||||
'src/extensions/core/slotDefaults.v2.ts',
|
||||
// W6.P3.D — defineWidget+mount showcase port (D-widget-converge / A12).
|
||||
'src/extensions/core/webcamCapture.v2.ts',
|
||||
// W6.P4.D — canvas-units canary + escape-hatch annotation example
|
||||
// (D-coord-space / A13).
|
||||
'src/extensions/core/coordSpaceDemo.v2.ts',
|
||||
// Reviewable .d.ts snapshots of the public surface — checked in for
|
||||
// diff-friendliness in PR reviews. Not imported (the live build emits
|
||||
// its own .d.ts under packages/extension-api/build/). Tracked under
|
||||
// PKG3.D2 / PKG2 hand-written declaration-file rationale.
|
||||
'packages/extension-api/api-snapshot/**',
|
||||
// Test framework harness for v2 extension migration. Consumed by
|
||||
// colocated *.v2.test.ts / *.migration.test.ts files; knip's vitest
|
||||
// entry resolution does not yet see these as test infra. Tracked by
|
||||
// I-TF (#12145).
|
||||
'src/extension-api-v2/harness/**'
|
||||
],
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
},
|
||||
vitest: {
|
||||
config: ['vitest?(.*).config.ts'],
|
||||
// I-TF (#12145) adds vitest.extension-api.config.mts; project uses
|
||||
// "type": "module" so vitest configs use the .mts extension.
|
||||
config: ['vitest?(.*).config.ts', 'vitest?(.*).config.mts'],
|
||||
entry: [
|
||||
'**/*.{bench,test,test-d,spec}.?(c|m)[jt]s?(x)',
|
||||
'**/__mocks__/**/*.[jt]s?(x)'
|
||||
@@ -79,7 +127,15 @@ const config: KnipConfig = {
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch',
|
||||
'-knipIgnoreUsedByStackedPR'
|
||||
'-knipIgnoreUsedByStackedPR',
|
||||
// Public API surface consumed externally by extension authors and the
|
||||
// TypeDoc docgen pipeline (PKG2). Mark exports with @publicAPI when they
|
||||
// are part of `@comfyorg/extension-api` but not internally referenced.
|
||||
'-publicAPI',
|
||||
// Per D20, the three *EntityId brand re-exports in src/extension-api/{node,widget}.ts
|
||||
// are demoted to @internal — they stay available for internal package modules
|
||||
// but are removed from the public barrel and from TypeDoc output.
|
||||
'-internal'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,12 @@ export default {
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
// Exclude package build directories from linting
|
||||
const filtered = fileNames.filter(
|
||||
(f) => !f.includes('/packages/') || !f.includes('/build/')
|
||||
)
|
||||
if (filtered.length === 0) return []
|
||||
const joinedPaths = toJoinedRelativePaths(filtered)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.4",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
@@ -47,6 +47,9 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:extension-api": "vitest run --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:watch": "vitest --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:coverage": "vitest run --config vitest.extension-api.config.mts --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
2
packages/extension-api/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
docs-build/
|
||||
node_modules/
|
||||
9
packages/extension-api/.npmignore
Normal file
@@ -0,0 +1,9 @@
|
||||
src/
|
||||
scripts/
|
||||
tsconfig*.json
|
||||
typedoc.json
|
||||
docs-build/
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
__tests__/
|
||||
node_modules/
|
||||
50
packages/extension-api/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @comfyorg/extension-api
|
||||
|
||||
> **Status**: scaffolded. Package implementation pending PKG3 — see
|
||||
> `../../../plans/P2-extension-api-package.md` and
|
||||
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
|
||||
|
||||
The official TypeScript declaration package for ComfyUI extensions. This
|
||||
package replaces the practice of vendoring `comfy.d.ts` files in custom
|
||||
node repos.
|
||||
|
||||
## Install (post-publish)
|
||||
|
||||
```bash
|
||||
pnpm add -D @comfyorg/extension-api
|
||||
```
|
||||
|
||||
```ts
|
||||
import { defineExtension } from '@comfyorg/extension-api'
|
||||
|
||||
export default defineExtension({
|
||||
name: 'MyExtension',
|
||||
setup(ctx) {
|
||||
ctx.onNodeMounted((node) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Source
|
||||
|
||||
This package is built from the source-of-truth folder
|
||||
`../../src/extension-api/`. Do not edit the package's `build/` output
|
||||
directly.
|
||||
|
||||
## Versioning
|
||||
|
||||
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
|
||||
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
|
||||
surface has stabilized.
|
||||
- Breaking changes follow semver strictly from `1.0.0` onward.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
|
||||
- `plans/P2-extension-api-package.md` — package structure plan
|
||||
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
|
||||
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
|
||||
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
|
||||
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration
|
||||
38
packages/extension-api/api-snapshot/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# API snapshot
|
||||
|
||||
Generated `.d.ts` files for the public surface of `@comfyorg/extension-api`.
|
||||
Committed to git so reviewers can see exactly what extension authors will
|
||||
consume — without having to build the package locally.
|
||||
|
||||
## Source of truth
|
||||
|
||||
These files are **generated** from the hand-written sources at
|
||||
`src/extension-api/**` (in the foundation PR / ComfyUI_frontend root).
|
||||
Do not edit them directly — they are regenerated by:
|
||||
|
||||
```bash
|
||||
pnpm --filter @comfyorg/extension-api build
|
||||
```
|
||||
|
||||
…which writes the same files to `packages/extension-api/build/extension-api/`.
|
||||
Copy the result into this folder when the public surface changes.
|
||||
|
||||
## Why a snapshot, not the live `build/`?
|
||||
|
||||
`build/` is gitignored (it's a build artifact). Committing a separate
|
||||
snapshot under a stable path gives reviewers a diffable record of any
|
||||
public-API change without polluting git with the runtime `.js` and
|
||||
declaration files emitted for every internal module.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Source |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| `index.d.ts` | `src/extension-api/index.ts` (barrel — entry point) |
|
||||
| `events.d.ts` | `src/extension-api/events.ts` |
|
||||
| `identifiers.d.ts` | `src/extension-api/identifiers.ts` |
|
||||
| `lifecycle.d.ts` | `src/extension-api/lifecycle.ts` |
|
||||
| `node.d.ts` | `src/extension-api/node.ts` |
|
||||
| `shell.d.ts` | `src/extension-api/shell.ts` |
|
||||
| `types.d.ts` | `src/extension-api/types.ts` |
|
||||
| `widget.d.ts` | `src/extension-api/widget.ts` |
|
||||
39
packages/extension-api/api-snapshot/events.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared event infrastructure for the ComfyUI extension API.
|
||||
*
|
||||
* @stability stable
|
||||
* @packageDocumentation
|
||||
*/
|
||||
/**
|
||||
* A typed event handler function.
|
||||
*
|
||||
* @typeParam E - The event payload type.
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* const handler: Handler<WidgetValueChangeEvent<number>> = (e) => {
|
||||
* console.log(e.oldValue, '->', e.newValue)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type Handler<E> = (event: E) => void
|
||||
/**
|
||||
* A typed async-capable event handler. Only valid for events that explicitly
|
||||
* support async handling (currently only `beforeSerialize`).
|
||||
*
|
||||
* @typeParam E - The event payload type.
|
||||
* @stability stable
|
||||
*/
|
||||
export type AsyncHandler<E> = (event: E) => void | Promise<void>
|
||||
/**
|
||||
* Cleanup function returned by `on()` — call to remove the listener.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* const off = node.on('executed', handler)
|
||||
* // later:
|
||||
* off()
|
||||
* ```
|
||||
*/
|
||||
export type Unsubscribe = () => void
|
||||
23
packages/extension-api/api-snapshot/identifiers.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Node identity helpers — re-exported from internal `nodeIdentification.ts`.
|
||||
*
|
||||
* `NodeLocatorId` and `NodeExecutionId` are the two stable node identity
|
||||
* primitives in the public API. All extension-facing code that needs to
|
||||
* reference a node across subgraph boundaries or execution runs should use
|
||||
* these rather than raw LiteGraph integer node IDs.
|
||||
*
|
||||
* @stability stable
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export type {
|
||||
NodeLocatorId,
|
||||
NodeExecutionId
|
||||
} from '../types/nodeIdentification'
|
||||
export {
|
||||
isNodeLocatorId,
|
||||
isNodeExecutionId,
|
||||
parseNodeLocatorId,
|
||||
createNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
createNodeExecutionId
|
||||
} from '../types/nodeIdentification'
|
||||
105
packages/extension-api/api-snapshot/index.d.ts
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @comfyorg/extension-api — Public Extension API for ComfyUI
|
||||
*
|
||||
* This barrel is the published package entry point. Every export here is
|
||||
* part of the stable public contract that extension authors depend on.
|
||||
*
|
||||
* Import directly — no dependency on `window.app` at module evaluation time:
|
||||
*
|
||||
* ```ts
|
||||
* import { defineNodeExtension, defineExtension } from '@comfyorg/extension-api'
|
||||
* ```
|
||||
*
|
||||
* ## API surface overview
|
||||
*
|
||||
* | Export | Purpose |
|
||||
* |--------|---------|
|
||||
* | `defineNodeExtension` | Register a node-scoped extension (the primary entry point) |
|
||||
* | `defineExtension` | Register an app-scoped extension (init, setup, shell UI) |
|
||||
* | `onNodeMounted`, `onNodeRemoved` | Implicit-context lifecycle hooks (call inside nodeCreated) |
|
||||
* | `NodeHandle` | Controlled access to node state and events |
|
||||
* | `WidgetHandle` | Controlled access to widget state and events |
|
||||
* | `WidgetBeforeQueueEvent` | Pre-queue validation event — call `reject(msg)` to cancel |
|
||||
* | `SlotInfo` | Read-only slot snapshot |
|
||||
* | `NodeEntityId`, `WidgetEntityId`, `SlotEntityId` | Branded entity IDs |
|
||||
* | Shell UI types | `SidebarTabExtension`, `BottomPanelExtension`, `CommandManager`, etc. |
|
||||
* | Identity helpers | `NodeLocatorId`, `NodeExecutionId`, parsers, type guards |
|
||||
*
|
||||
* ## API style (D3.3)
|
||||
*
|
||||
* The public API is **event + getter/setter**, not signals. Vue reactivity is
|
||||
* the internal engine; extension authors never import from Vue or use
|
||||
* `ref`/`computed`/`effect` directly. State is read via methods (`getValue()`,
|
||||
* `getPosition()`), mutated via command-dispatch methods (`setValue()`,
|
||||
* `setPosition()`), and observed via typed event subscriptions (`on('executed', fn)`).
|
||||
* Read-only invariants (set at construction, never change) are exposed as
|
||||
* accessors (`get entityId`, `get type`).
|
||||
*
|
||||
* ## Barrel-file rule exception
|
||||
*
|
||||
* ComfyUI_frontend AGENTS.md rule #19 normally forbids barrel files in `/src`.
|
||||
* This barrel is the **published package entry point** — not an internal
|
||||
* re-export — and is the explicit exception documented in AGENTS.md.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export type {
|
||||
ExtensionOptions,
|
||||
NodeExtensionOptions,
|
||||
WidgetExtensionOptions
|
||||
} from './types'
|
||||
export {
|
||||
defineExtension,
|
||||
defineNodeExtension,
|
||||
defineWidgetExtension
|
||||
} from '../services/extension-api-service'
|
||||
export { onNodeMounted, onNodeRemoved } from './lifecycle'
|
||||
export type {
|
||||
NodeHandle,
|
||||
NodeEntityId,
|
||||
SlotEntityId,
|
||||
SlotInfo,
|
||||
SlotDirection,
|
||||
NodeMode,
|
||||
Point,
|
||||
Size,
|
||||
DOMWidgetOptions,
|
||||
NodeExecutedEvent,
|
||||
NodeConnectedEvent,
|
||||
NodeDisconnectedEvent,
|
||||
NodePositionChangedEvent,
|
||||
NodeSizeChangedEvent,
|
||||
NodeModeChangedEvent,
|
||||
NodeBeforeSerializeEvent
|
||||
} from './node'
|
||||
export type {
|
||||
WidgetHandle,
|
||||
WidgetEntityId,
|
||||
WidgetValue,
|
||||
WidgetOptions,
|
||||
WidgetValueChangeEvent,
|
||||
WidgetOptionChangeEvent,
|
||||
WidgetPropertyChangeEvent,
|
||||
WidgetBeforeSerializeEvent,
|
||||
WidgetBeforeQueueEvent
|
||||
} from './widget'
|
||||
export type { Handler, AsyncHandler, Unsubscribe } from './events'
|
||||
export type {
|
||||
SidebarTabExtension,
|
||||
BottomPanelExtension,
|
||||
VueExtension,
|
||||
CustomExtension,
|
||||
ToastMessageOptions,
|
||||
ToastManager,
|
||||
ExtensionManager,
|
||||
CommandManager
|
||||
} from './shell'
|
||||
export type { NodeLocatorId, NodeExecutionId } from './identifiers'
|
||||
export {
|
||||
isNodeLocatorId,
|
||||
isNodeExecutionId,
|
||||
parseNodeLocatorId,
|
||||
createNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
createNodeExecutionId
|
||||
} from './identifiers'
|
||||
154
packages/extension-api/api-snapshot/lifecycle.d.ts
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
NodeExtensionOptions,
|
||||
ExtensionOptions,
|
||||
WidgetExtensionOptions
|
||||
} from './types'
|
||||
/**
|
||||
* Extension lifecycle — `defineExtension`, `defineNodeExtension`, and
|
||||
* the implicit-context lifecycle hooks (`onNodeMounted`, `onNodeRemoved`).
|
||||
*
|
||||
* Design decisions (D10):
|
||||
* - D10a: `currentExtension` global, Vue-style. Hook factories read the slot
|
||||
* implicitly. Lifecycle hooks must be called synchronously inside `setup()`.
|
||||
* - D10b: Hook firing order = registration order with lexicographic tie-break
|
||||
* on extension name.
|
||||
* - D10c: `setup()` is synchronous. `async setup` throws in dev, emits
|
||||
* console.error in prod.
|
||||
* - D10d: The object returned by `setup()` is wrapped with `proxyRefs()` so
|
||||
* callers read `entity.extensionState['my-ext'].count` without `.value`.
|
||||
*
|
||||
* Entry-point design (D6 Part 1): module-level import only. Extensions do NOT
|
||||
* depend on `window.app` being initialized at registration time.
|
||||
*
|
||||
* @stability stable
|
||||
* @packageDocumentation
|
||||
*/
|
||||
/**
|
||||
* @publicAPI
|
||||
* Back-compat re-exports of the extension option contracts. Prefer importing
|
||||
* from `@comfyorg/extension-api` (or `@/extension-api`); the
|
||||
* `@/extension-api/lifecycle` path is preserved for downstream code that
|
||||
* imported these types from the original module.
|
||||
*/
|
||||
export type {
|
||||
NodeExtensionOptions,
|
||||
ExtensionOptions,
|
||||
WidgetExtensionOptions
|
||||
} from './types'
|
||||
/**
|
||||
* Register a node extension. The runtime calls `nodeCreated` or
|
||||
* `loadedGraphNode` once per node entity matching `nodeTypes`.
|
||||
*
|
||||
* This is the primary entry point for extensions that interact with nodes and
|
||||
* widgets. Import directly from `@comfyorg/extension-api` — no dependency on
|
||||
* `window.app` at module evaluation time (D6 Part 1).
|
||||
*
|
||||
* Hook firing order across multiple extensions on the same entity follows
|
||||
* extension registration order with a lexicographic tie-break on `name` (D10b).
|
||||
*
|
||||
* @stability stable
|
||||
* @publicAPI
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineNodeExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineNodeExtension({
|
||||
* name: 'Comfy.PreviewAny',
|
||||
* nodeTypes: ['PreviewAny'],
|
||||
*
|
||||
* nodeCreated(node) {
|
||||
* const preview = node.addWidget('STRING', 'preview', '', {
|
||||
* multiline: true, readonly: true, serialize: false
|
||||
* })
|
||||
* node.on('executed', (e) => {
|
||||
* preview.setValue(String(e.output['text'] ?? ''))
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export declare function defineNodeExtension(
|
||||
options: NodeExtensionOptions
|
||||
): NodeExtensionOptions
|
||||
/**
|
||||
* Register an extension for app-wide lifecycle and shell UI contributions.
|
||||
*
|
||||
* Use `defineNodeExtension` for node/widget interactions. Use this for
|
||||
* `init`, `setup`, sidebar tabs, commands, and other app-level concerns.
|
||||
*
|
||||
* @stability stable
|
||||
* @publicAPI
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineExtension({
|
||||
* name: 'my-org.my-extension',
|
||||
* setup() {
|
||||
* console.log('Extension ready')
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export declare function defineExtension(
|
||||
options: ExtensionOptions
|
||||
): ExtensionOptions
|
||||
/**
|
||||
* Register a custom widget type. Called once at module load time to declare
|
||||
* a new widget kind.
|
||||
*
|
||||
* @stability experimental
|
||||
* @publicAPI
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineWidgetExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineWidgetExtension({
|
||||
* name: 'my-org.color-picker',
|
||||
* type: 'COLOR_PICKER'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export declare function defineWidgetExtension(
|
||||
options: WidgetExtensionOptions
|
||||
): WidgetExtensionOptions
|
||||
export {
|
||||
/**
|
||||
* Register a callback to fire when the node entity is fully mounted to the
|
||||
* graph (the reactive mount watcher has run, the scope is active, and
|
||||
* `setup()` has completed).
|
||||
*
|
||||
* Must be called synchronously inside `nodeCreated` or `loadedGraphNode`.
|
||||
*
|
||||
* @stability experimental
|
||||
* @example
|
||||
* ```ts
|
||||
* nodeCreated(node) {
|
||||
* onNodeMounted(() => {
|
||||
* // Safe to access DOM widgets, canvas, etc.
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
onNodeMounted,
|
||||
/**
|
||||
* Register a callback to fire when the node entity is removed from the graph
|
||||
* (NOT on subgraph promotion, which is a DOM-move, not an unmount).
|
||||
*
|
||||
* Replaces `nodeType.prototype.onRemoved` patching (S2.N4 — 7+ repos,
|
||||
* 4.89 blast radius).
|
||||
*
|
||||
* Must be called synchronously inside `nodeCreated` or `loadedGraphNode`.
|
||||
*
|
||||
* @stability experimental
|
||||
* @example
|
||||
* ```ts
|
||||
* nodeCreated(node) {
|
||||
* onNodeRemoved(() => {
|
||||
* cleanup()
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
onNodeRemoved
|
||||
} from '../services/extension-api-service'
|
||||
472
packages/extension-api/api-snapshot/node.d.ts
vendored
Normal file
@@ -0,0 +1,472 @@
|
||||
import { AsyncHandler, Handler, Unsubscribe } from './events'
|
||||
import { WidgetHandle, WidgetOptions } from './widget'
|
||||
import { NodeEntityId } from '../world/entityIds'
|
||||
export type { NodeEntityId }
|
||||
/**
|
||||
* A 2D point as `[x, y]`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export type Point = [x: number, y: number]
|
||||
/**
|
||||
* A 2D size as `[width, height]`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export type Size = [width: number, height: number]
|
||||
/**
|
||||
* LiteGraph node execution mode.
|
||||
*
|
||||
* Numeric values match `LGraphEventMode` in the LiteGraph runtime.
|
||||
*
|
||||
* - `0` — `ALWAYS`: execute every run (default).
|
||||
* - `1` — `ON_EVENT`: legacy slot for the dead trigger/action subsystem;
|
||||
* has no behavioural effect in the current scheduler. Reserved for ABI
|
||||
* compatibility — do not use in new extensions.
|
||||
* - `2` — `NEVER`: muted; node is skipped during execution.
|
||||
* - `3` — `ON_TRIGGER`: legacy slot for the dead trigger/action subsystem;
|
||||
* gated behind `LiteGraph.do_add_triggers_slots` (always `false`). Reserved
|
||||
* for ABI compatibility — do not use in new extensions.
|
||||
* - `4` — `BYPASS`: passthrough; inputs are forwarded to outputs without
|
||||
* running the node.
|
||||
*
|
||||
* Practical extension code should use `0` (always) or `2` (never/muted) or
|
||||
* `4` (bypass). Slots `1` and `3` are documented for completeness but their
|
||||
* runtime semantics are pending the AUDIT-LG trigger-subsystem cleanup.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export type NodeMode = 0 | 1 | 2 | 3 | 4
|
||||
/**
|
||||
* Direction of a slot on a node.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export type SlotDirection = 'input' | 'output'
|
||||
/**
|
||||
* Read-only snapshot of a single slot (input or output) on a node.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export interface SlotInfo {
|
||||
/** Branded entity ID for this slot. */
|
||||
readonly entityId: SlotEntityId
|
||||
/** Slot name as declared in `INPUT_TYPES` or `addInput`/`addOutput`. */
|
||||
readonly name: string
|
||||
/** Slot type string (e.g. `'IMAGE'`, `'LATENT'`, `'*'`). */
|
||||
readonly type: string
|
||||
/** Whether this is an input or output slot. */
|
||||
readonly direction: SlotDirection
|
||||
/** The node this slot belongs to. */
|
||||
readonly nodeEntityId: NodeEntityId
|
||||
}
|
||||
/**
|
||||
* Branded entity ID for slots. Prevents mixing slot IDs with node/widget IDs.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export type SlotEntityId = number & {
|
||||
readonly __brand: 'SlotEntityId'
|
||||
}
|
||||
/**
|
||||
* Payload for `node.on('executed', handler)`.
|
||||
*
|
||||
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* node.on('executed', (e) => {
|
||||
* const text = e.output['text'] as string[]
|
||||
* previewWidget.setValue(text.join('\n'))
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface NodeExecutedEvent {
|
||||
/** The backend execution output for this node. Shape varies by node type. */
|
||||
readonly output: Record<string, unknown>
|
||||
}
|
||||
/**
|
||||
* Payload for `node.on('connected', handler)`.
|
||||
*
|
||||
* Replaces `nodeType.prototype.onConnectInput` / `onConnectOutput` and
|
||||
* `nodeType.prototype.onConnectionsChange` patching.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export interface NodeConnectedEvent {
|
||||
/** The local slot that was connected. */
|
||||
readonly slot: SlotInfo
|
||||
/** The remote slot on the other node. */
|
||||
readonly remote: SlotInfo
|
||||
}
|
||||
/**
|
||||
* Payload for `node.on('disconnected', handler)`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export interface NodeDisconnectedEvent {
|
||||
/** The local slot that was disconnected. */
|
||||
readonly slot: SlotInfo
|
||||
}
|
||||
/**
|
||||
* Payload for `node.on('positionChanged', handler)`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export interface NodePositionChangedEvent {
|
||||
/** The new position. */
|
||||
readonly pos: Point
|
||||
}
|
||||
/**
|
||||
* Payload for `node.on('sizeChanged', handler)`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export interface NodeSizeChangedEvent {
|
||||
/** The new size. */
|
||||
readonly size: Size
|
||||
}
|
||||
/**
|
||||
* Payload for `node.on('modeChanged', handler)`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export interface NodeModeChangedEvent {
|
||||
/** The new execution mode. */
|
||||
readonly mode: NodeMode
|
||||
}
|
||||
/**
|
||||
* Payload for `node.on('beforeSerialize', handler)`.
|
||||
*
|
||||
* The node-level equivalent of `WidgetBeforeSerializeEvent`. Replaces both
|
||||
* `node.onSerialize` and `nodeType.prototype.serialize` patching patterns
|
||||
* (v1 S2.N6, S2.N15 touch-points).
|
||||
*
|
||||
* Mutate `event.data` in place to append extra fields (replaces `onSerialize`).
|
||||
* Call `event.replace(fn)` to wrap the entire serialized object (replaces
|
||||
* `prototype.serialize = function(){ const r = orig.call(this); … }`).
|
||||
*
|
||||
* @stability experimental
|
||||
* @example
|
||||
* ```ts
|
||||
* // Append a field
|
||||
* node.on('beforeSerialize', (e) => {
|
||||
* e.data['my_extra'] = computeExtra()
|
||||
* })
|
||||
*
|
||||
* // Wrap the serialized object
|
||||
* node.on('beforeSerialize', (e) => {
|
||||
* e.replace((orig) => ({ ...orig, wrapped: true }))
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface NodeBeforeSerializeEvent {
|
||||
/** Which serialization path triggered this. */
|
||||
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
|
||||
/**
|
||||
* The mutable serialized node object. Mutate in place to append fields.
|
||||
* Type intentionally loose — the exact shape is `ISerialisedNode`.
|
||||
*/
|
||||
readonly data: Record<string, unknown>
|
||||
/**
|
||||
* Replace the serialized object by providing a transform function.
|
||||
* `fn` receives the current `data` and should return the replacement.
|
||||
* Calling this multiple times chains: each call's `fn` receives the
|
||||
* previous call's output.
|
||||
*/
|
||||
replace(fn: (orig: Record<string, unknown>) => Record<string, unknown>): void
|
||||
}
|
||||
/**
|
||||
* Options for `NodeHandle.addDOMWidget()`.
|
||||
*
|
||||
* @stability experimental
|
||||
*/
|
||||
export interface DOMWidgetOptions {
|
||||
/** Unique widget name within this node. */
|
||||
name: string
|
||||
/** The DOM element to embed in the node widget area. */
|
||||
element: HTMLElement
|
||||
/** Reserved height in pixels. Defaults to `element.offsetHeight` at mount time. */
|
||||
height?: number
|
||||
}
|
||||
/**
|
||||
* Controlled surface for node access. Reads query the ECS World; writes
|
||||
* dispatch commands. Events are Vue-reactive watches on World components.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineNodeExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineNodeExtension({
|
||||
* name: 'my-size-enforcer',
|
||||
* nodeTypes: ['MyCustomNode'],
|
||||
*
|
||||
* nodeCreated(node) {
|
||||
* const [w, h] = node.getSize()
|
||||
* node.setSize([Math.max(w, 300), Math.max(h, 200)])
|
||||
*
|
||||
* node.on('executed', (e) => {
|
||||
* console.log('output:', e.output)
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface NodeHandle {
|
||||
/**
|
||||
* Stable entity ID for this node. Branded to prevent mixing with
|
||||
* `WidgetEntityId` at compile time.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
readonly entityId: NodeEntityId
|
||||
/**
|
||||
* The LiteGraph node type string (e.g. `'KSampler'`).
|
||||
* Read-only invariant: set at construction, never changes.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
readonly type: string
|
||||
/**
|
||||
* The ComfyUI backend class name (e.g. `'KSampler'`).
|
||||
* Equal to `type` for most nodes; differs for reroute/virtual nodes.
|
||||
* Read-only invariant.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
readonly comfyClass: string
|
||||
/**
|
||||
* Returns the node's current canvas position as `[x, y]`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
getPosition(): Point
|
||||
/**
|
||||
* Moves the node to a new canvas position. Dispatches a `MoveNode` command.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setPosition(pos: Point): void
|
||||
/**
|
||||
* Returns the node's current size as `[width, height]`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
getSize(): Size
|
||||
/**
|
||||
* Resizes the node. Dispatches a `ResizeNode` command.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setSize(size: Size): void
|
||||
/**
|
||||
* Returns the node's display title. Defaults to the node type string.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
getTitle(): string
|
||||
/**
|
||||
* Sets the node's display title. Dispatches a `SetNodeVisual` command.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setTitle(title: string): void
|
||||
/**
|
||||
* Returns `true` if the node is currently selected on the canvas.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
isSelected(): boolean
|
||||
/**
|
||||
* Returns the node's current execution mode.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
getMode(): NodeMode
|
||||
/**
|
||||
* Sets the node's execution mode. Dispatches a `SetNodeMode` command.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setMode(mode: NodeMode): void
|
||||
/**
|
||||
* Returns a per-node-instance property by key.
|
||||
*
|
||||
* In v2, prefer routing persistent state through widget values or
|
||||
* `beforeSerialize` events. `node.properties` is kept as a migration shim
|
||||
* for v1 extensions that used it for per-instance widget config (e.g. min/max).
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
getProperty<T = unknown>(key: string): T | undefined
|
||||
/**
|
||||
* Returns a copy of all per-node-instance properties.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
getProperties(): Record<string, unknown>
|
||||
/**
|
||||
* Sets a per-node-instance property. Dispatches a `SetNodeProperty` command.
|
||||
*
|
||||
* In v2, prefer `widget.setOption(key, value)` for widget-scoped per-instance
|
||||
* config (it persists to the `widget_options` sidecar in the workflow JSON).
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setProperty(key: string, value: unknown): void
|
||||
/**
|
||||
* Returns a `WidgetHandle` for the named widget, or `undefined` if no such
|
||||
* widget exists on this node.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* const steps = node.widget('steps')
|
||||
* if (steps) steps.setValue(20)
|
||||
* ```
|
||||
*/
|
||||
widget(name: string): WidgetHandle | undefined
|
||||
/**
|
||||
* Returns all widgets on this node as `WidgetHandle` instances.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
widgets(): readonly WidgetHandle[]
|
||||
/**
|
||||
* Adds a new widget to this node.
|
||||
*
|
||||
* @param type - Widget type string (e.g. `'INT'`, `'STRING'`, `'COMBO'`).
|
||||
* @param name - Unique widget name on this node.
|
||||
* @param defaultValue - Initial value.
|
||||
* @param options - Optional type-specific options.
|
||||
* @returns The new `WidgetHandle`.
|
||||
* @stability stable
|
||||
*/
|
||||
addWidget(
|
||||
type: string,
|
||||
name: string,
|
||||
defaultValue: unknown,
|
||||
options?: Partial<WidgetOptions>
|
||||
): WidgetHandle
|
||||
/**
|
||||
* Adds a DOM-backed widget to this node.
|
||||
*
|
||||
* Replaces the v1 `node.addDOMWidget(name, type, element, opts)` pattern.
|
||||
* The runtime automatically:
|
||||
* - Reserves node height for the element (via auto-computeSize integration).
|
||||
* - Removes the element from the DOM when the node is removed.
|
||||
* - Includes the widget in `NodeHandle.widgets()`.
|
||||
*
|
||||
* Use `WidgetHandle.setHeight(px)` to resize the reservation after initial mount.
|
||||
*
|
||||
* @param opts.name - Unique widget name on this node.
|
||||
* @param opts.element - The DOM element to embed.
|
||||
* @param opts.height - Initial reserved height in pixels. Defaults to `element.offsetHeight`.
|
||||
* @returns A `WidgetHandle` for the registered DOM widget.
|
||||
* @stability experimental
|
||||
*/
|
||||
addDOMWidget(opts: DOMWidgetOptions): WidgetHandle
|
||||
/**
|
||||
* Returns all input slots on this node.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
inputs(): readonly SlotInfo[]
|
||||
/**
|
||||
* Returns all output slots on this node.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
outputs(): readonly SlotInfo[]
|
||||
/**
|
||||
* Subscribe to node removal (graph deletion, not subgraph promotion).
|
||||
*
|
||||
* Replaces the v1 `nodeType.prototype.onRemoved` patching pattern.
|
||||
* Does NOT fire on subgraph promotion — the node's entity ID is preserved
|
||||
* across promotion (see D9 Phase A notes and D12).
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(event: 'removed', handler: Handler<void>): Unsubscribe
|
||||
/**
|
||||
* Subscribe to backend execution completion for this node.
|
||||
*
|
||||
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern (the
|
||||
* most widely used anti-pattern per R4-P3; 5+ confirmed repos).
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(event: 'executed', handler: Handler<NodeExecutedEvent>): Unsubscribe
|
||||
/**
|
||||
* Subscribe to workflow hydration (node loaded from a saved workflow).
|
||||
*
|
||||
* Replaces the v1 `nodeType.prototype.onConfigure` / `loadedGraphNode`
|
||||
* patterns. Fires after all widget values are restored from the workflow JSON.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(event: 'configured', handler: Handler<void>): Unsubscribe
|
||||
/**
|
||||
* Subscribe to slot connection events.
|
||||
*
|
||||
* Replaces `nodeType.prototype.onConnectInput`, `onConnectOutput`, and
|
||||
* `onConnectionsChange` patching patterns (R4-P4: six distinct signatures
|
||||
* in the wild — this single typed event resolves the confusion).
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(event: 'connected', handler: Handler<NodeConnectedEvent>): Unsubscribe
|
||||
/**
|
||||
* Subscribe to slot disconnection events.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(
|
||||
event: 'disconnected',
|
||||
handler: Handler<NodeDisconnectedEvent>
|
||||
): Unsubscribe
|
||||
/**
|
||||
* Subscribe to canvas position changes.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(
|
||||
event: 'positionChanged',
|
||||
handler: Handler<NodePositionChangedEvent>
|
||||
): Unsubscribe
|
||||
/**
|
||||
* Subscribe to node size changes.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(event: 'sizeChanged', handler: Handler<NodeSizeChangedEvent>): Unsubscribe
|
||||
/**
|
||||
* Subscribe to execution mode changes.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(event: 'modeChanged', handler: Handler<NodeModeChangedEvent>): Unsubscribe
|
||||
/**
|
||||
* Subscribe to node serialization. Async-capable.
|
||||
*
|
||||
* Replaces `nodeType.prototype.onSerialize` and `nodeType.prototype.serialize`
|
||||
* patching patterns. Collapses four v1 serialization surfaces to one (D7 Part 4).
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability experimental
|
||||
*/
|
||||
on(
|
||||
event: 'beforeSerialize',
|
||||
handler: AsyncHandler<NodeBeforeSerializeEvent>
|
||||
): Unsubscribe
|
||||
}
|
||||
21
packages/extension-api/api-snapshot/shell.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shell UI extension types — sidebar tabs, bottom panels, commands, toasts.
|
||||
*
|
||||
* Re-exported from `src/types/extensionTypes.ts` with no shape changes.
|
||||
* The original module remains the source of truth; this barrel makes the
|
||||
* shell types available from the single `@comfyorg/extension-api` package
|
||||
* entry point.
|
||||
*
|
||||
* @stability stable
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export type {
|
||||
SidebarTabExtension,
|
||||
BottomPanelExtension,
|
||||
VueExtension,
|
||||
CustomExtension,
|
||||
ToastMessageOptions,
|
||||
ToastManager,
|
||||
ExtensionManager,
|
||||
CommandManager
|
||||
} from '../types/extensionTypes'
|
||||
171
packages/extension-api/api-snapshot/types.d.ts
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
import { NodeHandle } from './node'
|
||||
import { WidgetHandle } from './widget'
|
||||
/**
|
||||
* Options for `defineNodeExtension`. Describes an extension that reacts to
|
||||
* node lifecycle events.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineNodeExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineNodeExtension({
|
||||
* name: 'my-org.my-extension',
|
||||
* nodeTypes: ['KSampler'],
|
||||
*
|
||||
* nodeCreated(node) {
|
||||
* node.on('executed', (e) => console.log('done', e.output))
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface NodeExtensionOptions {
|
||||
/**
|
||||
* Globally unique extension name. Used for scope registry keying, hook
|
||||
* ordering (D10b lexicographic tie-break), and debug messages.
|
||||
*
|
||||
* Convention: `'org.extension-name'` or `'Comfy.ExtensionName'`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Filter to specific `comfyClass` names. When omitted, the extension
|
||||
* receives `nodeCreated` / `loadedGraphNode` for every node type.
|
||||
*
|
||||
* Replaces the v1 `beforeRegisterNodeDef` filtering pattern (DEP1).
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* nodeTypes: ['KSampler', 'KSamplerAdvanced']
|
||||
* ```
|
||||
*/
|
||||
nodeTypes?: string[]
|
||||
/**
|
||||
* Called once per node instance when the node is first created (typed in,
|
||||
* pasted from clipboard, duplicated, or loaded without an existing workflow).
|
||||
*
|
||||
* - Runs inside a Vue `EffectScope`. All `watch` / `computed` / `onNodeMounted`
|
||||
* calls made here are captured and disposed automatically on node removal.
|
||||
* - Must be synchronous (D10c). Kick off async work inside the body; use
|
||||
* `loading: ref(true)` for async-dependent state.
|
||||
* - Called only once per entity ID lifetime. Copy/paste creates a fresh entity
|
||||
* and fires `nodeCreated` again on the new entity (D12 reset-to-fresh).
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
nodeCreated?(node: NodeHandle): void
|
||||
/**
|
||||
* Called once per node instance when the node is restored from a saved
|
||||
* workflow. Widget values are already populated when this fires.
|
||||
*
|
||||
* Same rules as `nodeCreated`. Exactly one of `nodeCreated` or
|
||||
* `loadedGraphNode` fires per node entity, never both.
|
||||
*
|
||||
* Replaces the v1 `loadedGraphNode` hook (which had near-zero real usage per
|
||||
* R4-P11) and `nodeType.prototype.onConfigure` patching.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
loadedGraphNode?(node: NodeHandle): void
|
||||
}
|
||||
/**
|
||||
* Options for the global `defineExtension` entry point. Covers extension-wide
|
||||
* lifecycle and shell UI contributions.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineExtension({
|
||||
* name: 'my-org.my-extension',
|
||||
* async setup() {
|
||||
* // App is ready; register commands, sidebar tabs, etc.
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface ExtensionOptions {
|
||||
/**
|
||||
* Globally unique extension name. Matches the format of
|
||||
* `NodeExtensionOptions.name`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Declared API version of this extension. Used by the telemetry system to
|
||||
* track v1 → v2 adoption (D6 Phase D gate: "<5% v1 usage before dropping
|
||||
* the v1 bridge"). Set to `'2'` for extensions written against this API.
|
||||
*
|
||||
* Optional in Phase A (no runtime enforcement). The runtime reads this field
|
||||
* via `getExtensionVersionReport()` to produce adoption metrics.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* defineExtension({ name: 'my-ext', apiVersion: '2', setup() { … } })
|
||||
* ```
|
||||
*/
|
||||
apiVersion?: string
|
||||
/**
|
||||
* Runs once during app initialization (after the app is mounted but before
|
||||
* the first workflow is loaded). Equivalent to the v1 `ComfyExtension.init`.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
init?(): void | Promise<void>
|
||||
/**
|
||||
* Runs once after the app and all core extensions are initialized. Equivalent
|
||||
* to the v1 `ComfyExtension.setup`. Safe to call shell UI registration APIs
|
||||
* (`ExtensionManager`, `CommandManager`) here.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setup?(): void | Promise<void>
|
||||
}
|
||||
/**
|
||||
* Options for `defineWidgetExtension`. Describes an extension that provides a
|
||||
* custom widget type with its own DOM rendering.
|
||||
*
|
||||
* @stability experimental
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineWidgetExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineWidgetExtension({
|
||||
* name: 'my-org.color-picker',
|
||||
* type: 'COLOR_PICKER',
|
||||
*
|
||||
* widgetCreated(widget, node) {
|
||||
* return {
|
||||
* // mount color picker DOM
|
||||
* render(container) {},
|
||||
* // cleanup
|
||||
* destroy() {}
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetExtensionOptions {
|
||||
/** Globally unique extension name. */
|
||||
name: string
|
||||
/** Widget type string this extension provides (e.g. `'COLOR_PICKER'`). */
|
||||
type: string
|
||||
/**
|
||||
* Called once per widget instance. Return a `{ render, destroy }` pair for
|
||||
* custom DOM rendering, or `void` for non-visual widgets.
|
||||
*
|
||||
* @stability experimental
|
||||
*/
|
||||
widgetCreated?(
|
||||
widget: WidgetHandle,
|
||||
parentNode: NodeHandle | null
|
||||
): {
|
||||
render(container: HTMLElement): void
|
||||
destroy?(): void
|
||||
} | void
|
||||
}
|
||||
472
packages/extension-api/api-snapshot/widget.d.ts
vendored
Normal file
@@ -0,0 +1,472 @@
|
||||
import { AsyncHandler, Handler, Unsubscribe } from './events'
|
||||
import { WidgetEntityId } from '../world/entityIds'
|
||||
export type { WidgetEntityId }
|
||||
/**
|
||||
* The union of all legal widget scalar values. Complex widgets (DOM, canvas)
|
||||
* may return their own serializable shapes.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export type WidgetValue = string | number | boolean | null
|
||||
/**
|
||||
* Payload for `widget.on('valueChange', handler)`.
|
||||
*
|
||||
* Replaces the v1 `widget.callback` pattern.
|
||||
*
|
||||
* @typeParam T - The widget's value type.
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* widget.on('valueChange', (e) => {
|
||||
* console.log('changed from', e.oldValue, 'to', e.newValue)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetValueChangeEvent<T = WidgetValue> {
|
||||
/** Value before the change. */
|
||||
readonly oldValue: T
|
||||
/** Value after the change. */
|
||||
readonly newValue: T
|
||||
}
|
||||
/**
|
||||
* Payload for `widget.on('optionChange', handler)`.
|
||||
*
|
||||
* Fires when a type-specific option is mutated via `setOption(key, value)`.
|
||||
* The exact set of observable option keys is type-dependent (e.g. `min`,
|
||||
* `max`, `step` for numeric widgets; `multiline` for strings).
|
||||
*
|
||||
* The data model for "options" vs "first-class fields" is defined in D7.
|
||||
* This event covers the options-bag tier (type-specific, not every-widget).
|
||||
*
|
||||
* @stability experimental — full semantics deferred to D7
|
||||
* @example
|
||||
* ```ts
|
||||
* widget.on('optionChange', (e) => {
|
||||
* if (e.key === 'min') clampValue(e.newValue as number)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetOptionChangeEvent {
|
||||
/** The option key that changed (e.g. `'min'`, `'max'`, `'multiline'`). */
|
||||
readonly key: string
|
||||
/** Value before the change. */
|
||||
readonly oldValue: unknown
|
||||
/** Value after the change. */
|
||||
readonly newValue: unknown
|
||||
}
|
||||
/**
|
||||
* Payload for `widget.on('propertyChange', handler)`.
|
||||
*
|
||||
* Fires when a first-class every-widget property is mutated — specifically
|
||||
* `hidden`, `disabled`, and `serialize` (the non-value first-class fields
|
||||
* defined in D7 Part 1). Does NOT fire for `value` changes (use `valueChange`)
|
||||
* or for options-bag mutations (use `optionChange`).
|
||||
*
|
||||
* @stability experimental — property enumeration finalised in D7
|
||||
* @example
|
||||
* ```ts
|
||||
* widget.on('propertyChange', (e) => {
|
||||
* if (e.property === 'hidden') updateLayout(e.newValue as boolean)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetPropertyChangeEvent {
|
||||
/**
|
||||
* Which first-class property changed.
|
||||
* - `'hidden'` — visibility toggled via `setHidden()`
|
||||
* - `'disabled'` — enabled/disabled via `setDisabled()`
|
||||
* - `'serialize'` — serialization opt-in/out via `setSerializeEnabled()`
|
||||
*/
|
||||
readonly property: 'hidden' | 'disabled' | 'serialize'
|
||||
/** Value before the change. */
|
||||
readonly oldValue: boolean
|
||||
/** Value after the change. */
|
||||
readonly newValue: boolean
|
||||
}
|
||||
/**
|
||||
* Payload for `widget.on('beforeSerialize', handler)`.
|
||||
*
|
||||
* This is the **only async-allowed event** in v1 (per D10c / D5 Part 3).
|
||||
* Replaces `widget.serializeValue`, `widget.options.serialize = false`, and
|
||||
* the v1 `widget.serializeValue = (workflowNode, widgetIndex) => ...` pattern.
|
||||
*
|
||||
* Call `event.setSerializedValue(v)` to override what is written to
|
||||
* `widgets_values[i]` and the API prompt. Call `event.skip()` to exclude this
|
||||
* widget from the prompt entirely. Do not call either to pass through the
|
||||
* widget's current `getValue()` unchanged.
|
||||
*
|
||||
* @typeParam T - The widget's value type.
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* // Dynamic prompts: replace value at serialize time
|
||||
* widget.on('beforeSerialize', (e) => {
|
||||
* if (e.context === 'prompt') {
|
||||
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // Preview widget: exclude from prompt
|
||||
* widget.on('beforeSerialize', (e) => {
|
||||
* if (e.context === 'prompt') e.skip()
|
||||
* })
|
||||
*
|
||||
* // Async: webcam capture — materialize frame before prompt builds
|
||||
* widget.on('beforeSerialize', async (e) => {
|
||||
* if (e.context === 'prompt') {
|
||||
* const frame = await captureFrame()
|
||||
* e.setSerializedValue(frame)
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
|
||||
/**
|
||||
* Which serialization path triggered this handler.
|
||||
*
|
||||
* - `'workflow'` — user is saving the workflow to disk (full round-trip).
|
||||
* - `'prompt'` — user is queueing a run (only prompt-relevant data sent to backend).
|
||||
* - `'clone'` — a copy/paste is happening; the framework already populated the
|
||||
* cloned entity's widget value from the source. Override only if the clone should
|
||||
* differ from the source. (See D12 for scope copy semantics.)
|
||||
* - `'subgraph-promote'` — the widget is being promoted to a subgraph IO slot.
|
||||
*/
|
||||
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
|
||||
/**
|
||||
* The widget's current value at the time of serialization (before any override).
|
||||
* Equivalent to calling `widget.getValue()`.
|
||||
*/
|
||||
readonly value: T
|
||||
/**
|
||||
* Override the serialized value. The provided value is written to
|
||||
* `widgets_values[i]` (and to the API prompt for `context='prompt'`).
|
||||
* Calling this multiple times keeps the last call's value.
|
||||
*
|
||||
* @param v - The value to serialize. Must be JSON-serializable.
|
||||
*/
|
||||
setSerializedValue(v: unknown): void
|
||||
/**
|
||||
* Exclude this widget from the API prompt entirely.
|
||||
* Only meaningful for `context='prompt'`; no-ops on other contexts.
|
||||
* Replaces `widget.options.serialize = false` and `() => undefined` patterns.
|
||||
*/
|
||||
skip(): void
|
||||
}
|
||||
/**
|
||||
* Payload for `widget.on('beforeQueue', handler)`.
|
||||
*
|
||||
* Fires when the user triggers a prompt queue (before `graphToPrompt` runs).
|
||||
* Call `event.reject(message)` to cancel the queue attempt with a user-visible
|
||||
* error. Do not call `reject` to allow the queue to proceed.
|
||||
*
|
||||
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
|
||||
* for per-widget validation (e.g. required field empty, value out of range).
|
||||
* For cross-node/graph-wide rejection, see the app-level `beforePrompt` event
|
||||
* (I-UWF.4 — not yet in the API).
|
||||
*
|
||||
* @stability experimental
|
||||
* @example
|
||||
* ```ts
|
||||
* // Reject if a required field is empty
|
||||
* widget.on('beforeQueue', (e) => {
|
||||
* if (!widget.getValue()) {
|
||||
* e.reject('Prompt text is required before queueing.')
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // Reject with a dynamic message
|
||||
* widget.on('beforeQueue', (e) => {
|
||||
* const val = widget.getValue<number>()
|
||||
* const min = widget.getOption<number>('min') ?? 0
|
||||
* if (val < min) {
|
||||
* e.reject(`Value ${val} is below the minimum of ${min}.`)
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetBeforeQueueEvent {
|
||||
/**
|
||||
* Reject the queue attempt, showing `message` to the user.
|
||||
* Once any handler calls `reject`, the queue is cancelled — subsequent
|
||||
* handlers still run but their `reject` calls are no-ops.
|
||||
*
|
||||
* @param message - Human-readable reason shown in the UI toast.
|
||||
*/
|
||||
reject(message: string): void
|
||||
}
|
||||
/**
|
||||
* Controlled surface for widget access. Backed by ECS `WidgetValue` and
|
||||
* `WidgetIdentity` components in the World. Reads query components directly;
|
||||
* writes dispatch commands (undo-able, serializable, validatable).
|
||||
*
|
||||
* All views (node, properties panel, promoted copy) share the same backing
|
||||
* `WidgetEntityId`, so mutations from any source trigger `valueChange`.
|
||||
*
|
||||
* @typeParam T - The type of `getValue()` / `setValue()`. Defaults to `WidgetValue`.
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* import { defineNodeExtension } from '@comfyorg/extension-api'
|
||||
*
|
||||
* export default defineNodeExtension({
|
||||
* name: 'my-extension',
|
||||
* nodeCreated(node) {
|
||||
* const steps = node.widget('steps')
|
||||
* if (!steps) return
|
||||
*
|
||||
* steps.on('valueChange', (e) => console.log('steps =', e.newValue))
|
||||
* steps.setOption('min', 1)
|
||||
* steps.setOption('max', 150)
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetHandle<T = WidgetValue> {
|
||||
/**
|
||||
* Stable entity identifier for this widget. Branded to prevent mixing with
|
||||
* `NodeEntityId` at compile time.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
readonly entityId: WidgetEntityId
|
||||
/**
|
||||
* The widget's name as registered in `INPUT_TYPES` or `addWidget`. Stable
|
||||
* for the lifetime of the node; never changes after creation.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
readonly name: string
|
||||
/**
|
||||
* The widget's type string (e.g. `'INT'`, `'STRING'`, `'COMBO'`,
|
||||
* `'MARKDOWN'`). Read-only invariant set at creation.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
readonly widgetType: string
|
||||
/**
|
||||
* Returns the widget's current user-edited value.
|
||||
*
|
||||
* @typeParam T - Narrows the return type when you know the widget type.
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* const steps = node.widget('steps')!.getValue<number>()
|
||||
* ```
|
||||
*/
|
||||
getValue(): T
|
||||
/**
|
||||
* Sets the widget's value. Dispatches a `SetWidgetValue` command (undo-able).
|
||||
* Triggers `valueChange` handlers on all views.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setValue(value: T): void
|
||||
/**
|
||||
* Returns `true` if the widget is currently hidden from the node UI.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
isHidden(): boolean
|
||||
/**
|
||||
* Show or hide the widget. Dispatches a `SetWidgetHidden` command.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* toggle.on('valueChange', (e) => {
|
||||
* detail.setHidden(!e.newValue)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
setHidden(hidden: boolean): void
|
||||
/**
|
||||
* Returns `true` if the widget is disabled (read-only in the UI).
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
isDisabled(): boolean
|
||||
/**
|
||||
* Enable or disable the widget.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setDisabled(disabled: boolean): void
|
||||
/**
|
||||
* The widget's display label shown to the user. Defaults to the widget name.
|
||||
* Read-only invariant per D6 Part 3 (set at creation, never changes after).
|
||||
*
|
||||
* To override at construction, pass `label` to `addWidget()` options.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
readonly label: string
|
||||
/**
|
||||
* Updates the reserved height for this DOM widget and triggers a node relayout.
|
||||
*
|
||||
* Only meaningful for widgets registered via `NodeHandle.addDOMWidget()`.
|
||||
* For non-DOM widgets this is a no-op.
|
||||
*
|
||||
* Replaces the v1 pattern of re-assigning `node.computeSize` to return a new
|
||||
* height whenever the embedded element resizes.
|
||||
*
|
||||
* @param px - New reserved height in pixels.
|
||||
* @stability experimental
|
||||
*/
|
||||
setHeight(px: number): void
|
||||
/**
|
||||
* Returns `true` if this widget is included in workflow and prompt
|
||||
* serialization. Defaults to `true` for all widget types.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
isSerializeEnabled(): boolean
|
||||
/**
|
||||
* Enable or disable serialization for this widget. When disabled, the widget
|
||||
* is excluded from both `widgets_values` in the workflow JSON and the API
|
||||
* prompt payload. Equivalent to the v1 `widget.options.serialize = false`
|
||||
* pattern.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
setSerializeEnabled(enabled: boolean): void
|
||||
/**
|
||||
* Returns the per-instance override for `key`, or the class-default value
|
||||
* from `INPUT_TYPES` if no override has been set, or `undefined` if the key
|
||||
* is unknown for this widget type.
|
||||
*
|
||||
* Type-specific option names: `min`, `max`, `step` (INT/FLOAT); `multiline`,
|
||||
* `dynamicPrompts` (STRING); `image_folder`, `upload_to` (upload widgets).
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* const min = widget.getOption<number>('min') ?? 0
|
||||
* ```
|
||||
*/
|
||||
getOption<K = unknown>(key: string): K | undefined
|
||||
/**
|
||||
* Set a per-instance option override. Persisted as a `widget_options` sidecar
|
||||
* in the workflow JSON (additive, backward-compatible). Does not change the
|
||||
* backend prompt schema unless the extension explicitly opts in via
|
||||
* `beforeSerialize`.
|
||||
*
|
||||
* @stability stable
|
||||
* @example
|
||||
* ```ts
|
||||
* // Primitive Int/Float per-instance config (replaces node.properties anti-pattern)
|
||||
* widget.setOption('min', 0)
|
||||
* widget.setOption('max', 100)
|
||||
* widget.setOption('step', 1)
|
||||
* ```
|
||||
*/
|
||||
setOption(key: string, value: unknown): void
|
||||
/**
|
||||
* Subscribe to the widget's value changes.
|
||||
*
|
||||
* Replaces the v1 `widget.callback` pattern.
|
||||
* Fires synchronously after the value is committed (per D10c).
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(
|
||||
event: 'valueChange',
|
||||
handler: Handler<WidgetValueChangeEvent<T>>
|
||||
): Unsubscribe
|
||||
/**
|
||||
* Subscribe to type-specific option mutations (`setOption(key, value)`).
|
||||
*
|
||||
* Fires for options-bag changes (e.g. `min`, `max`, `step`, `multiline`).
|
||||
* Does NOT fire for value changes or first-class field changes.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability experimental
|
||||
*/
|
||||
on(
|
||||
event: 'optionChange',
|
||||
handler: Handler<WidgetOptionChangeEvent>
|
||||
): Unsubscribe
|
||||
/**
|
||||
* Subscribe to first-class property mutations (`setHidden`, `setDisabled`,
|
||||
* `setSerializeEnabled`).
|
||||
*
|
||||
* Does NOT fire for `setValue` (use `valueChange`) or options-bag mutations
|
||||
* (use `optionChange`).
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability experimental
|
||||
*/
|
||||
on(
|
||||
event: 'propertyChange',
|
||||
handler: Handler<WidgetPropertyChangeEvent>
|
||||
): Unsubscribe
|
||||
/**
|
||||
* Subscribe to widget serialization. The only async-allowed event (D10c / D5).
|
||||
*
|
||||
* Replaces `widget.serializeValue = fn` and the v1 `widget.options.serialize`
|
||||
* flag. The handler may be sync or async; async handlers are awaited before
|
||||
* the serialization payload is sent.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability stable
|
||||
*/
|
||||
on(
|
||||
event: 'beforeSerialize',
|
||||
handler: AsyncHandler<WidgetBeforeSerializeEvent<T>>
|
||||
): Unsubscribe
|
||||
/**
|
||||
* Subscribe to pre-queue validation. Fires before `graphToPrompt` runs.
|
||||
*
|
||||
* Call `event.reject(message)` to cancel the queue with a user-visible error.
|
||||
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
|
||||
* for per-widget validation use cases.
|
||||
*
|
||||
* Handlers are sync-only — use for validation logic only, not I/O.
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability experimental
|
||||
*/
|
||||
on(
|
||||
event: 'beforeQueue',
|
||||
handler: Handler<WidgetBeforeQueueEvent>
|
||||
): Unsubscribe
|
||||
}
|
||||
/**
|
||||
* Options passed to `node.addWidget()` when creating a new widget.
|
||||
*
|
||||
* Type-specific keys (e.g. `min`, `max`, `step` for numeric widgets;
|
||||
* `multiline`, `dynamicPrompts` for strings) are passed through as-is.
|
||||
*
|
||||
* @stability stable
|
||||
*/
|
||||
export interface WidgetOptions {
|
||||
/** If `true`, the widget is hidden from the node UI on creation. */
|
||||
hidden?: boolean
|
||||
/** If `true`, the widget is rendered read-only (no user editing). */
|
||||
readonly?: boolean
|
||||
/** If `false`, this widget is excluded from workflow/prompt serialization. */
|
||||
serialize?: boolean
|
||||
/** Display label override. Defaults to the widget `name`. */
|
||||
label?: string
|
||||
/** Toggle label shown when value is `true` (BOOLEAN widgets). */
|
||||
labelOn?: string
|
||||
/** Toggle label shown when value is `false` (BOOLEAN widgets). */
|
||||
labelOff?: string
|
||||
/** Multiline text input (STRING widgets). */
|
||||
multiline?: boolean
|
||||
/**
|
||||
* When `true`, the widget value is processed for dynamic prompt syntax
|
||||
* at serialize time. (STRING widgets with `dynamicPrompts: true`.)
|
||||
*/
|
||||
dynamicPrompts?: boolean
|
||||
/** Min value for numeric widgets (INT, FLOAT). */
|
||||
min?: number
|
||||
/** Max value for numeric widgets. */
|
||||
max?: number
|
||||
/** Step size for numeric widgets. */
|
||||
step?: number
|
||||
/** Default value at construction time. */
|
||||
default?: unknown
|
||||
/** Any additional type-specific option. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
2
packages/extension-api/build/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
index.js
|
||||
index.js.map
|
||||
1254
packages/extension-api/build/index.d.ts
vendored
Normal file
41
packages/extension-api/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@comfyorg/extension-api",
|
||||
"version": "0.1.0",
|
||||
"description": "Official TypeScript extension API for ComfyUI custom nodes",
|
||||
"files": [
|
||||
"build",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "./build/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./build/index.d.ts",
|
||||
"import": "./build/index.js",
|
||||
"default": "./build/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "vite build --logLevel warn",
|
||||
"build": "vite build",
|
||||
"docs:build": "tsx scripts/build-docs.ts",
|
||||
"docs:watch": "tsx scripts/build-docs.ts --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"typedoc": "0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.6.3",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-dts": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:api"
|
||||
]
|
||||
}
|
||||
}
|
||||
495
packages/extension-api/scripts/build-docs.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env tsx
|
||||
/* eslint-disable no-console -- CLI build script; stdout progress is intentional */
|
||||
|
||||
/**
|
||||
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
|
||||
*
|
||||
* Steps:
|
||||
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
|
||||
* 2. Post-process each markdown file:
|
||||
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
|
||||
* - Convert ``` fences without lang tag → ```ts
|
||||
* - Replace raw [TypeName] cross-refs with MDX relative links
|
||||
* - Wrap @example blocks in proper code fences
|
||||
* 3. Write final .mdx files to docs-build/mintlify/
|
||||
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
|
||||
*
|
||||
* Run: pnpm --filter @comfyorg/extension-api docs:build
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pkgRoot = path.resolve(__dirname, '..')
|
||||
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
|
||||
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
|
||||
const watchMode = process.argv.includes('--watch')
|
||||
|
||||
// ── Page metadata ────────────────────────────────────────────────────────────
|
||||
// Controls frontmatter for each generated page. Key = TypeDoc output filename
|
||||
// stem (lowercased). Unrecognised files get generic metadata.
|
||||
|
||||
interface PageMeta {
|
||||
title: string
|
||||
sidebarTitle?: string
|
||||
description: string
|
||||
icon?: string
|
||||
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
|
||||
order: number
|
||||
}
|
||||
|
||||
const PAGE_META: Record<string, PageMeta> = {
|
||||
// Top-level overview
|
||||
index: {
|
||||
title: 'Extension API Overview',
|
||||
description: 'TypeScript API reference for ComfyUI custom node extensions.',
|
||||
icon: 'puzzle-piece',
|
||||
group: 'root',
|
||||
order: 0
|
||||
},
|
||||
// Lifecycle / registration
|
||||
defineextension: {
|
||||
title: 'defineExtension',
|
||||
description:
|
||||
'Register an app-scoped extension for init, setup, and shell UI contributions.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 1
|
||||
},
|
||||
definenodeextension: {
|
||||
title: 'defineNodeExtension',
|
||||
description:
|
||||
'Register a node-scoped extension reacting to node lifecycle events.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 2
|
||||
},
|
||||
definewidgetextension: {
|
||||
title: 'defineWidgetExtension',
|
||||
description: 'Register a custom widget type with its own DOM rendering.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 3
|
||||
},
|
||||
extensionoptions: {
|
||||
title: 'ExtensionOptions',
|
||||
description:
|
||||
'Options object for defineExtension — app-wide lifecycle and shell UI.',
|
||||
group: 'core',
|
||||
order: 4
|
||||
},
|
||||
nodeextensionoptions: {
|
||||
title: 'NodeExtensionOptions',
|
||||
description:
|
||||
'Options object for defineNodeExtension — node lifecycle hooks.',
|
||||
group: 'core',
|
||||
order: 5
|
||||
},
|
||||
widgetextensionoptions: {
|
||||
title: 'WidgetExtensionOptions',
|
||||
description:
|
||||
'Options object for defineWidgetExtension — custom widget rendering.',
|
||||
group: 'core',
|
||||
order: 6
|
||||
},
|
||||
onnoderemoved: {
|
||||
title: 'onNodeRemoved',
|
||||
sidebarTitle: 'onNodeRemoved',
|
||||
description:
|
||||
'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
|
||||
group: 'core',
|
||||
order: 7
|
||||
},
|
||||
onnodemounted: {
|
||||
title: 'onNodeMounted',
|
||||
sidebarTitle: 'onNodeMounted',
|
||||
description:
|
||||
'Implicit-context lifecycle hook: fires when a node is fully mounted.',
|
||||
group: 'core',
|
||||
order: 8
|
||||
},
|
||||
// Handles
|
||||
nodehandle: {
|
||||
title: 'NodeHandle',
|
||||
description:
|
||||
'Controlled access to node state, mutations, slots, and events.',
|
||||
icon: 'circle-nodes',
|
||||
group: 'handles',
|
||||
order: 10
|
||||
},
|
||||
widgethandle: {
|
||||
title: 'WidgetHandle',
|
||||
description: 'Controlled access to widget state, mutations, and events.',
|
||||
icon: 'sliders',
|
||||
group: 'handles',
|
||||
order: 11
|
||||
},
|
||||
slotinfo: {
|
||||
title: 'SlotInfo',
|
||||
description: 'Read-only snapshot of a node slot (input or output).',
|
||||
group: 'handles',
|
||||
order: 12
|
||||
},
|
||||
// Events
|
||||
nodeexecutedevent: {
|
||||
title: 'NodeExecutedEvent',
|
||||
description: 'Payload fired when a node finishes execution.',
|
||||
group: 'events',
|
||||
order: 20
|
||||
},
|
||||
nodeconnectedevent: {
|
||||
title: 'NodeConnectedEvent',
|
||||
description: 'Payload fired when a slot connection is made.',
|
||||
group: 'events',
|
||||
order: 21
|
||||
},
|
||||
nodedisconnectedevent: {
|
||||
title: 'NodeDisconnectedEvent',
|
||||
description: 'Payload fired when a slot connection is removed.',
|
||||
group: 'events',
|
||||
order: 22
|
||||
},
|
||||
nodepositionchangedevent: {
|
||||
title: 'NodePositionChangedEvent',
|
||||
description: 'Payload fired when a node is moved on the canvas.',
|
||||
group: 'events',
|
||||
order: 23
|
||||
},
|
||||
nodesizechangedevent: {
|
||||
title: 'NodeSizeChangedEvent',
|
||||
description: 'Payload fired when a node is resized.',
|
||||
group: 'events',
|
||||
order: 24
|
||||
},
|
||||
nodemodechangedevent: {
|
||||
title: 'NodeModeChangedEvent',
|
||||
description: 'Payload fired when a node execution mode changes.',
|
||||
group: 'events',
|
||||
order: 25
|
||||
},
|
||||
nodebeforeserializeevent: {
|
||||
title: 'NodeBeforeSerializeEvent',
|
||||
description: 'Pre-serialization hook payload — override or skip node data.',
|
||||
group: 'events',
|
||||
order: 26
|
||||
},
|
||||
widgetvaluechangeevent: {
|
||||
title: 'WidgetValueChangeEvent',
|
||||
description: 'Payload fired when a widget value changes.',
|
||||
group: 'events',
|
||||
order: 27
|
||||
},
|
||||
widgetbeforeserializeevent: {
|
||||
title: 'WidgetBeforeSerializeEvent',
|
||||
description:
|
||||
'Pre-serialization hook payload — override or skip widget value.',
|
||||
group: 'events',
|
||||
order: 28
|
||||
},
|
||||
widgetbeforequeueevent: {
|
||||
title: 'WidgetBeforeQueueEvent',
|
||||
description:
|
||||
'Pre-queue validation payload — call reject() to cancel queue.',
|
||||
group: 'events',
|
||||
order: 29
|
||||
},
|
||||
// Shell UI
|
||||
sidebartabextension: {
|
||||
title: 'SidebarTabExtension',
|
||||
description: 'Register a custom sidebar tab.',
|
||||
group: 'shell',
|
||||
order: 40
|
||||
},
|
||||
bottompanelextension: {
|
||||
title: 'BottomPanelExtension',
|
||||
description: 'Register a custom bottom panel tab.',
|
||||
group: 'shell',
|
||||
order: 41
|
||||
},
|
||||
toastmanager: {
|
||||
title: 'ToastManager',
|
||||
description: 'Show toast notifications to the user.',
|
||||
group: 'shell',
|
||||
order: 42
|
||||
},
|
||||
commandmanager: {
|
||||
title: 'CommandManager',
|
||||
description: 'Register keyboard shortcuts and command palette entries.',
|
||||
group: 'shell',
|
||||
order: 43
|
||||
},
|
||||
extensionmanager: {
|
||||
title: 'ExtensionManager',
|
||||
description: 'Access shell UI registration APIs.',
|
||||
group: 'shell',
|
||||
order: 44
|
||||
},
|
||||
// Identity
|
||||
nodelocatorid: {
|
||||
title: 'NodeLocatorId',
|
||||
description:
|
||||
'Branded string ID that uniquely locates a node across graph snapshots.',
|
||||
group: 'identity',
|
||||
order: 50
|
||||
},
|
||||
nodeexecutionid: {
|
||||
title: 'NodeExecutionId',
|
||||
description: 'Branded string ID for a specific node execution run.',
|
||||
group: 'identity',
|
||||
order: 51
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<PageMeta['group'], string> = {
|
||||
root: 'Extensions API',
|
||||
core: 'Registration',
|
||||
handles: 'Handles',
|
||||
events: 'Events',
|
||||
shell: 'Shell UI',
|
||||
identity: 'Identity'
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function slug(stem: string): string {
|
||||
return stem
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function metaFor(stem: string): PageMeta {
|
||||
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
|
||||
return (
|
||||
PAGE_META[key] ?? {
|
||||
title: stem,
|
||||
description: `API reference for ${stem}.`,
|
||||
group: 'core',
|
||||
order: 99
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
|
||||
function toMintlifyMdx(raw: string, stem: string): string {
|
||||
const meta = metaFor(stem)
|
||||
|
||||
// Build frontmatter
|
||||
const fm: string[] = [
|
||||
`---`,
|
||||
`title: "${meta.title}"`,
|
||||
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
|
||||
`description: "${meta.description}"`,
|
||||
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
|
||||
`---`
|
||||
]
|
||||
|
||||
let body = raw
|
||||
|
||||
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
|
||||
|
||||
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
|
||||
body = body.replace(/^# .+\n+/, '')
|
||||
|
||||
// Ensure opening code fences that have no lang tag get `ts`
|
||||
// Only match a ``` that is immediately followed by a newline (opening fence),
|
||||
// not a closing fence (which also has just ``` + newline but we can detect
|
||||
// by context: opening fences follow non-fence lines; closing fences follow content).
|
||||
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
|
||||
// We track state via a flag pass instead of a single regex.
|
||||
let inBlock = false
|
||||
body = body
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (inBlock) {
|
||||
if (line.trim() === '```') {
|
||||
inBlock = false
|
||||
return line
|
||||
}
|
||||
return line
|
||||
}
|
||||
if (line.startsWith('```')) {
|
||||
if (line.trim() === '```') {
|
||||
// bare opening fence → add ts
|
||||
inBlock = true
|
||||
return '```ts'
|
||||
}
|
||||
// has a lang tag already
|
||||
inBlock = true
|
||||
return line
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// TypeDoc emits `typescript` lang tag; normalize to `ts`
|
||||
body = body.replace(/^```typescript\b/gm, '```ts')
|
||||
|
||||
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
|
||||
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
|
||||
(_match, label, _category, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
// Same-dir links
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
|
||||
(_match, label, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
|
||||
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
|
||||
// code examples to be directly under prose without a sub-heading.
|
||||
// Flatten "## Example\n\n```ts" → "```ts"
|
||||
body = body.replace(/^## Example\s*\n+/gm, '')
|
||||
|
||||
// Stability tags: render as a <Tip> callout
|
||||
body = body.replace(
|
||||
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
|
||||
(_match, level) => {
|
||||
const label =
|
||||
level === 'stable'
|
||||
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
|
||||
: level === 'experimental'
|
||||
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
|
||||
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
|
||||
return label
|
||||
}
|
||||
)
|
||||
|
||||
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
|
||||
body = body.replace(
|
||||
/^Stability: (stable|experimental|deprecated)\s*$/gm,
|
||||
(_match, level) => {
|
||||
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
|
||||
if (level === 'experimental')
|
||||
return '<Warning>**Stability:** Experimental</Warning>'
|
||||
return '<Warning>**Stability:** Deprecated</Warning>'
|
||||
}
|
||||
)
|
||||
|
||||
return [...fm, '', body.trim(), ''].join('\n')
|
||||
}
|
||||
|
||||
// ── Nav snippet builder ───────────────────────────────────────────────────────
|
||||
|
||||
interface NavPage {
|
||||
group?: string
|
||||
pages: (string | NavPage)[]
|
||||
}
|
||||
|
||||
function buildNavSnippet(stems: string[]): NavPage {
|
||||
// Sort stems by order then group by category
|
||||
const sortedStems = stems
|
||||
.slice()
|
||||
.sort((a, b) => metaFor(a).order - metaFor(b).order)
|
||||
const sortedByGroup: Record<string, string[]> = {}
|
||||
for (const stem of sortedStems) {
|
||||
const group = metaFor(stem).group
|
||||
if (!sortedByGroup[group]) sortedByGroup[group] = []
|
||||
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
|
||||
}
|
||||
|
||||
const groupOrder: PageMeta['group'][] = [
|
||||
'root',
|
||||
'core',
|
||||
'handles',
|
||||
'events',
|
||||
'shell',
|
||||
'identity'
|
||||
]
|
||||
|
||||
const pages: (string | NavPage)[] = []
|
||||
|
||||
// Overview at top level
|
||||
if (sortedByGroup['root']) {
|
||||
for (const p of sortedByGroup['root']) pages.push(p)
|
||||
}
|
||||
|
||||
for (const grp of groupOrder) {
|
||||
if (grp === 'root') continue
|
||||
const grpPages = sortedByGroup[grp]
|
||||
if (!grpPages?.length) continue
|
||||
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
|
||||
}
|
||||
|
||||
return { group: 'Extensions API', pages }
|
||||
}
|
||||
|
||||
// ── Main pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
function runTypedoc(): void {
|
||||
console.log('▶ Running TypeDoc...')
|
||||
execSync(
|
||||
`pnpm exec typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
|
||||
{ cwd: pkgRoot, stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
function processFiles(): void {
|
||||
if (!fs.existsSync(rawDir)) {
|
||||
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
|
||||
}
|
||||
|
||||
fs.mkdirSync(mintlifyDir, { recursive: true })
|
||||
|
||||
const mdFiles = fs
|
||||
.readdirSync(rawDir, { recursive: true })
|
||||
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
|
||||
|
||||
const stems: string[] = []
|
||||
|
||||
for (const relPath of mdFiles) {
|
||||
const src = path.join(rawDir, relPath)
|
||||
const stem = path.basename(relPath, '.md')
|
||||
const raw = fs.readFileSync(src, 'utf8')
|
||||
const mdx = toMintlifyMdx(raw, stem)
|
||||
|
||||
const destName = slug(stem) + '.mdx'
|
||||
const dest = path.join(mintlifyDir, destName)
|
||||
fs.writeFileSync(dest, mdx)
|
||||
console.log(` ✔ ${relPath} → mintlify/${destName}`)
|
||||
stems.push(stem)
|
||||
}
|
||||
|
||||
// Write nav snippet
|
||||
const nav = buildNavSnippet(stems)
|
||||
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
|
||||
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
|
||||
console.log(` ✔ nav-snippet.json`)
|
||||
|
||||
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
|
||||
console.log(` ${stems.length} pages + nav-snippet.json`)
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
runTypedoc()
|
||||
processFiles()
|
||||
}
|
||||
|
||||
if (watchMode) {
|
||||
// Simple watch: re-run on change to source files
|
||||
console.log('👁 Watch mode — watching src/extension-api/**')
|
||||
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
run()
|
||||
|
||||
fs.watch(srcDir, { recursive: true }, () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => {
|
||||
console.log('\n🔄 Source changed — rebuilding...')
|
||||
try {
|
||||
run()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
17
packages/extension-api/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @comfyorg/extension-api — Public Extension API for ComfyUI
|
||||
*
|
||||
* This is the package entry point compiled to `build/index.js` + `build/index.d.ts`.
|
||||
* It is a single re-export of the canonical surface defined in
|
||||
* `src/extension-api/index.ts` in the main app — that file is the one source
|
||||
* of truth for what is part of the stable, semver-versioned public contract.
|
||||
*
|
||||
* Do NOT add exports here. Add them to `src/extension-api/index.ts` and they
|
||||
* will flow through this barrel automatically.
|
||||
*
|
||||
* The tsconfig.json `paths` alias `@/*` → `../../src/*` resolves the import
|
||||
* below at both typecheck and build time.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export * from '@/extension-api/index'
|
||||
41
packages/extension-api/tsconfig.build.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "ES2023.Array", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"noEmit": false,
|
||||
"outDir": "./build",
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"],
|
||||
"@/utils/formatUtil": [
|
||||
"../../packages/shared-frontend-utils/src/formatUtil.ts"
|
||||
],
|
||||
"@/utils/networkUtil": [
|
||||
"../../packages/shared-frontend-utils/src/networkUtil.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../../src/**/*.ts",
|
||||
"../../src/types/litegraph-augmentation.d.ts",
|
||||
"../../global.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../../src/**/*.test.ts",
|
||||
"../../src/**/*.spec.ts",
|
||||
"../../src/**/*.vue",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"scripts/**"
|
||||
]
|
||||
}
|
||||
19
packages/extension-api/tsconfig.docs.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["../../src/extension-api/**/*.ts"],
|
||||
"exclude": [
|
||||
"../../src/**/*.test.ts",
|
||||
"../../src/**/*.spec.ts",
|
||||
"../../src/**/*.vue"
|
||||
]
|
||||
}
|
||||
8
packages/extension-api/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
}
|
||||
}
|
||||
45
packages/extension-api/typedoc.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"entryPoints": ["../../src/extension-api/index.ts"],
|
||||
"tsconfig": "./tsconfig.docs.json",
|
||||
"out": "./docs-build/raw",
|
||||
"plugin": ["typedoc-plugin-markdown"],
|
||||
"excludeInternal": true,
|
||||
"excludePrivate": true,
|
||||
"excludeProtected": true,
|
||||
"readme": "none",
|
||||
"skipErrorChecking": true,
|
||||
"githubPages": false,
|
||||
"blockTags": [
|
||||
"@stability",
|
||||
"@packageDocumentation",
|
||||
"@example",
|
||||
"@typeParam",
|
||||
"@returns",
|
||||
"@deprecated",
|
||||
"@remarks"
|
||||
],
|
||||
"hideGenerator": true,
|
||||
"useCodeBlocks": true,
|
||||
"flattenOutputFiles": false,
|
||||
"entryFileName": "index",
|
||||
"fileExtension": ".md",
|
||||
"outputFileStrategy": "members",
|
||||
"hidePageHeader": false,
|
||||
"hideBreadcrumbs": false,
|
||||
"useHTMLAnchors": false,
|
||||
"sanitizeComments": true,
|
||||
"expandObjects": false,
|
||||
"parametersFormat": "table",
|
||||
"propertiesFormat": "table",
|
||||
"typeDeclarationFormat": "table",
|
||||
"indexFormat": "table",
|
||||
"tableColumnSettings": {
|
||||
"hideDefaults": false,
|
||||
"hideInherited": false,
|
||||
"hideModifiers": false,
|
||||
"hideOverrides": false,
|
||||
"hideSources": true,
|
||||
"hideValues": false,
|
||||
"leftAlignHeaders": false
|
||||
}
|
||||
}
|
||||
74
packages/extension-api/vite.config.mts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import dts from 'vite-plugin-dts'
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url))
|
||||
const repoRoot = resolve(here, '..', '..')
|
||||
const repoSrc = resolve(repoRoot, 'src')
|
||||
const surfaceRoot = resolve(repoSrc, 'extension-api')
|
||||
|
||||
/**
|
||||
* Library build for `@comfyorg/extension-api`.
|
||||
*
|
||||
* Per ADR D17 (PKG2 build strategy), the package is built from the canonical
|
||||
* surface defined in the main app at `src/extension-api/index.ts`. Vite
|
||||
* resolves the `@/*` aliases against the main app's `src/` directory and
|
||||
* emits a single bundled `index.js` plus a single bundled `index.d.ts`.
|
||||
*
|
||||
* The package barrel at `packages/extension-api/src/index.ts` is the
|
||||
* Vite entry point and re-exports `@/extension-api/index` — preserving
|
||||
* "the barrel is the source of truth in main app `src/extension-api/`"
|
||||
* intent in `packages/extension-api/AGENTS.md`.
|
||||
*
|
||||
* Vue is externalized as a peer dependency (per D6.1 Phase A — extension
|
||||
* authors share the host app's Vue runtime).
|
||||
*/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/utils/formatUtil': resolve(
|
||||
repoRoot,
|
||||
'packages/shared-frontend-utils/src/formatUtil.ts'
|
||||
),
|
||||
'@/utils/networkUtil': resolve(
|
||||
repoRoot,
|
||||
'packages/shared-frontend-utils/src/networkUtil.ts'
|
||||
),
|
||||
'@': repoSrc
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(here, 'build'),
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
target: 'es2022',
|
||||
minify: false,
|
||||
lib: {
|
||||
// Build directly from the canonical surface in the main app — the
|
||||
// package's own `src/index.ts` exists only as a documented entry
|
||||
// point that re-exports the same surface, but we point Vite at the
|
||||
// canonical file so dts paths line up cleanly with the JS bundle.
|
||||
entry: resolve(surfaceRoot, 'index.ts'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'index.js'
|
||||
},
|
||||
rollupOptions: {
|
||||
// Vue is provided by the host app at runtime.
|
||||
external: ['vue', /^@vue\//]
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
// Bundle all types into a single index.d.ts. This ensures the package
|
||||
// is self-contained and doesn't reference paths outside build/.
|
||||
rollupTypes: true,
|
||||
outDir: resolve(here, 'build'),
|
||||
tsconfigPath: resolve(here, 'tsconfig.build.json'),
|
||||
logLevel: 'warn',
|
||||
// Only include the extension-api surface, not the entire app
|
||||
include: [resolve(surfaceRoot, '**/*.ts')]
|
||||
})
|
||||
]
|
||||
})
|
||||
4
packages/ingest-types/src/types.gen.ts
generated
@@ -523,10 +523,6 @@ export type ImportPublishedAssetsRequest = {
|
||||
* IDs of published assets (inputs and models) to import.
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* The share ID of the published workflow these assets belong to. Required for authorization.
|
||||
*/
|
||||
share_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
3
packages/ingest-types/src/zod.gen.ts
generated
@@ -310,8 +310,7 @@ export const zImportPublishedAssetsResponse = z.object({
|
||||
* Request body for importing assets from a published workflow.
|
||||
*/
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
|
||||
share_id: z.string().min(1).max(64)
|
||||
published_asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -10057,6 +10057,8 @@ export interface components {
|
||||
};
|
||||
progress: number;
|
||||
create_time: number;
|
||||
/** @description Actual credits consumed by the task. Present once status is finalized; 0 for failed tasks. */
|
||||
consumed_credit?: number;
|
||||
};
|
||||
TripoSuccessTask: {
|
||||
/** @enum {integer} */
|
||||
|
||||