diff --git a/.github/workflows/ci-tests-extension-api.yaml b/.github/workflows/ci-tests-extension-api.yaml new file mode 100644 index 0000000000..9fd7c3d034 --- /dev/null +++ b/.github/workflows/ci-tests-extension-api.yaml @@ -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@v5 + 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. diff --git a/package.json b/package.json index 275f3c49d5..6989a316c8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/extension-api/.gitignore b/packages/extension-api/.gitignore new file mode 100644 index 0000000000..43bb7e5561 --- /dev/null +++ b/packages/extension-api/.gitignore @@ -0,0 +1,3 @@ +docs-build/ +build/ +node_modules/ diff --git a/packages/extension-api/README.md b/packages/extension-api/README.md new file mode 100644 index 0000000000..c56f9bf3a5 --- /dev/null +++ b/packages/extension-api/README.md @@ -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 diff --git a/packages/extension-api/package.json b/packages/extension-api/package.json new file mode 100644 index 0000000000..75bc66ef52 --- /dev/null +++ b/packages/extension-api/package.json @@ -0,0 +1,28 @@ +{ + "name": "@comfyorg/extension-api", + "version": "0.1.0", + "description": "Official TypeScript extension API for ComfyUI custom nodes", + "type": "module", + "exports": { + ".": "./build/index.js" + }, + "types": "./build/index.d.ts", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc --emitDeclarationOnly --outDir 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:" + }, + "nx": { + "tags": [ + "scope:shared", + "type:api" + ] + } +} diff --git a/packages/extension-api/scripts/build-docs.ts b/packages/extension-api/scripts/build-docs.ts new file mode 100644 index 0000000000..26e3f7741a --- /dev/null +++ b/packages/extension-api/scripts/build-docs.ts @@ -0,0 +1,470 @@ +#!/usr/bin/env tsx +/** + * 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 = { + // 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 = { + 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 callout + body = body.replace( + /\*\*Stability\*\*: `(stable|experimental|deprecated)`/g, + (_match, level) => { + const label = + level === 'stable' + ? '**Stability:** Stable — part of the public API contract.' + : level === 'experimental' + ? '**Stability:** Experimental — may change before 1.0.' + : '**Stability:** Deprecated — will be removed. See migration guide.' + 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 '**Stability:** Stable' + if (level === 'experimental') return '**Stability:** Experimental' + return '**Stability:** Deprecated' + } + ) + + return [...fm, '', body.trim(), ''].join('\n') +} + +// ── Nav snippet builder ─────────────────────────────────────────────────────── + +interface NavPage { + group?: string + pages: (string | NavPage)[] +} + +function buildNavSnippet(stems: string[]): NavPage { + const byGroup: Record = {} + + for (const stem of stems) { + const meta = metaFor(stem) + const group = meta.group + if (!byGroup[group]) byGroup[group] = [] + byGroup[group].push(`extensions/api/${slug(stem)}`) + } + + // Sort each group by order + const sortedStems = stems.slice().sort((a, b) => metaFor(a).order - metaFor(b).order) + const sortedByGroup: Record = {} + 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( + `npx 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 | 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() +} diff --git a/packages/extension-api/tsconfig.docs.json b/packages/extension-api/tsconfig.docs.json new file mode 100644 index 0000000000..2699f818b4 --- /dev/null +++ b/packages/extension-api/tsconfig.docs.json @@ -0,0 +1,21 @@ +{ + "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" + ] +} diff --git a/packages/extension-api/typedoc.json b/packages/extension-api/typedoc.json new file mode 100644 index 0000000000..3338ea5425 --- /dev/null +++ b/packages/extension-api/typedoc.json @@ -0,0 +1,37 @@ +{ + "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 + } +} diff --git a/src/extension-api-v2/__tests__/bc-01.migration.test.ts b/src/extension-api-v2/__tests__/bc-01.migration.test.ts new file mode 100644 index 0000000000..aab3f658fd --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-01.migration.test.ts @@ -0,0 +1,39 @@ +// Category: BC.01 — Node lifecycle: creation +// DB cross-ref: S2.N1, S2.N8 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31 +// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships +// Migration: v1 nodeCreated(node) + beforeRegisterNodeDef → v2 defineNodeExtension({ nodeCreated(handle) }) + +import { describe, it } from 'vitest' + +describe('BC.01 migration — node lifecycle: creation', () => { + describe('nodeCreated parity (S2.N1)', () => { + it.todo( + 'v1 nodeCreated and v2 nodeCreated are both invoked the same number of times when N nodes are created' + ) + it.todo( + 'side-effects applied to the node in v1 nodeCreated(node) are reproducible via NodeHandle methods in v2' + ) + it.todo( + 'v2 nodeCreated fires in the same relative order as v1 for extensions registered in the same order' + ) + }) + + describe('beforeRegisterNodeDef → type-scoped defineNodeExtension (S2.N8)', () => { + it.todo( + 'prototype mutation applied in v1 beforeRegisterNodeDef produces the same per-instance behavior as v2 type-scoped nodeCreated' + ) + it.todo( + 'v2 type-scoped extension does not affect node types that were excluded, matching v1 type-guard behavior' + ) + }) + + describe('VueNode mount timing invariant', () => { + it.todo( + 'both v1 and v2 nodeCreated fire before VueNode mounts — extensions relying on this ordering do not need changes' + ) + it.todo( + 'extensions that deferred DOM work to a callback in v1 can use onNodeMounted in v2 for the same guarantee' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-01.v1.test.ts b/src/extension-api-v2/__tests__/bc-01.v1.test.ts new file mode 100644 index 0000000000..f7d273a13d --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-01.v1.test.ts @@ -0,0 +1,45 @@ +// Category: BC.01 — Node lifecycle: creation +// DB cross-ref: S2.N1, S2.N8 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31 +// Surface: S2.N1 = nodeCreated hook, S2.N8 = beforeRegisterNodeDef +// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships +// v1 contract: app.registerExtension({ nodeCreated(node) { ... } }) +// Note: nodeCreated fires BEFORE the VueNode Vue component mounts; extensions needing +// VueNode-backed state must defer (see BC.37). + +import { describe, it } from 'vitest' + +describe('BC.01 v1 contract — node lifecycle: creation', () => { + describe('S2.N1 — nodeCreated hook', () => { + it.todo( + 'nodeCreated is called once per node instance immediately after the node is constructed' + ) + it.todo( + 'nodeCreated receives the LGraphNode instance as its first argument' + ) + it.todo( + 'nodeCreated fires before the node is added to the graph (graph.nodes does not yet contain the node)' + ) + it.todo( + 'nodeCreated fires before the VueNode Vue component is mounted (vm.$el is null at call time)' + ) + it.todo( + 'properties set on node inside nodeCreated are accessible in subsequent lifecycle hooks' + ) + }) + + describe('S2.N8 — beforeRegisterNodeDef hook', () => { + it.todo( + 'beforeRegisterNodeDef is called once per node type before the type is registered in the node registry' + ) + it.todo( + 'beforeRegisterNodeDef receives the node constructor and the raw node definition object' + ) + it.todo( + 'prototype mutations made in beforeRegisterNodeDef affect all subsequently created instances of that type' + ) + it.todo( + 'beforeRegisterNodeDef is NOT called again on graph reload if the type is already registered' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-01.v2.test.ts b/src/extension-api-v2/__tests__/bc-01.v2.test.ts new file mode 100644 index 0000000000..0f16ac27ca --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-01.v2.test.ts @@ -0,0 +1,41 @@ +// Category: BC.01 — Node lifecycle: creation +// DB cross-ref: S2.N1, S2.N8 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31 +// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: defineNodeExtension({ nodeCreated(handle) { ... } }) +// Note: v2 nodeCreated receives a NodeHandle, not a raw LGraphNode. VueNode mount +// timing guarantee is unchanged — defer to onNodeMounted for Vue-backed state. + +import { describe, it } from 'vitest' + +describe('BC.01 v2 contract — node lifecycle: creation', () => { + describe('nodeCreated(handle) — per-instance setup', () => { + it.todo( + 'nodeCreated is called once per node instance and receives a NodeHandle wrapping the created node' + ) + it.todo( + 'NodeHandle.id is stable and matches the underlying LGraphNode id at call time' + ) + it.todo( + 'NodeHandle.type returns the registered node type string' + ) + it.todo( + 'state stored via NodeHandle.setState() inside nodeCreated is retrievable in subsequent hooks for the same instance' + ) + it.todo( + 'nodeCreated fires before VueNode mounts; accessing NodeHandle.vueRef inside nodeCreated returns null' + ) + }) + + describe('type-level registration (replacement for S2.N8)', () => { + it.todo( + 'defineNodeExtension({ types: [\"MyNode\"] }) scopes nodeCreated to only instances of the listed types' + ) + it.todo( + 'omitting types: causes nodeCreated to fire for every node type (global registration)' + ) + it.todo( + 'type-scoped registration does not receive nodeCreated calls for unregistered node types' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-02.migration.test.ts b/src/extension-api-v2/__tests__/bc-02.migration.test.ts new file mode 100644 index 0000000000..9d86599ed0 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-02.migration.test.ts @@ -0,0 +1,36 @@ +// Category: BC.02 — Node lifecycle: teardown +// DB cross-ref: S2.N4 +// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137 +// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships +// Migration: v1 node.onRemoved assignment → v2 defineNodeExtension({ onRemoved(handle) }) + +import { describe, it } from 'vitest' + +describe('BC.02 migration — node lifecycle: teardown', () => { + describe('invocation parity (S2.N4)', () => { + it.todo( + 'v1 onRemoved and v2 onRemoved are both called the same number of times for the same sequence of node removals' + ) + it.todo( + 'v2 onRemoved fires at the same point in the removal lifecycle as v1 (after node is detached from graph)' + ) + }) + + describe('resource cleanup equivalence', () => { + it.todo( + 'intervals cleared in v1 onRemoved are equally suppressible via NodeHandle.onDispose() in v2 without manual tracking' + ) + it.todo( + 'DOM elements removed manually in v1 onRemoved are automatically removed by v2 auto-disposal when registered via addDOMWidget()' + ) + it.todo( + 'observer.disconnect() patterns in v1 can be replaced by NodeHandle.onDispose(() => observer.disconnect()) in v2' + ) + }) + + describe('graph clear coverage', () => { + it.todo( + 'both v1 and v2 teardown hooks are invoked for all nodes when graph.clear() is called' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-02.v1.test.ts b/src/extension-api-v2/__tests__/bc-02.v1.test.ts new file mode 100644 index 0000000000..c65fdf6c91 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-02.v1.test.ts @@ -0,0 +1,135 @@ +// Category: BC.02 — Node lifecycle: teardown +// DB cross-ref: S2.N4 +// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137 +// Surface: S2.N4 = node.onRemoved +// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.onRemoved = function() { /* cleanup DOM, intervals, observers */ } +// +// I-TF.3.C3 — proof-of-concept harness wiring. +// Phase A harness limitation: MiniGraph.remove() deletes the entity from the World +// but does NOT automatically call onRemoved (that requires Phase B eval sandbox + +// LiteGraph prototype wiring). The wired tests below call onRemoved explicitly after +// graph.remove() to prove the harness mechanics and assertion patterns work. +// The TODO stubs below them track what needs Phase B to become real assertions. + +import { describe, expect, it, vi } from 'vitest' + +import { + countEvidenceExcerpts, + createHarnessWorld, + createMiniComfyApp, + loadEvidenceSnippet +} from '../harness' + +// ── Proof-of-concept wired tests (I-TF.3.C3) ──────────────────────────────── +// These pass today. They prove: (a) the harness can model the v1 teardown +// pattern, (b) removal is reflected in the World, (c) the cleanup callback +// fires when the extension calls it, (d) evidence excerpts load for S2.N4. + +describe('BC.02 v1 contract — node lifecycle: teardown [harness POC]', () => { + describe('S2.N4 — onRemoved harness mechanics', () => { + it('cleanup callback fires when extension calls it after graph.remove()', () => { + const world = createHarnessWorld() + const app = createMiniComfyApp(world) + + // v1 pattern: extension patches onRemoved on the node during nodeCreated. + // We model this as a plain function stored on a node-shaped object. + const cleanupFn = vi.fn() + const node = { + type: 'LTXVideo', + entityId: app.graph.add({ type: 'LTXVideo' }), + onRemoved: cleanupFn + } + + expect(world.findNode(node.entityId)).toBeDefined() + + // Simulate the LiteGraph removal sequence (Phase A: explicit call). + app.graph.remove(node.entityId) + node.onRemoved() + + expect(world.findNode(node.entityId)).toBeUndefined() + expect(cleanupFn).toHaveBeenCalledOnce() + }) + + it('cleanup callback does not fire if remove is never called', () => { + const world = createHarnessWorld() + const app = createMiniComfyApp(world) + const cleanupFn = vi.fn() + const entityId = app.graph.add({ type: 'KSampler' }) + + // Node exists; no removal; callback should not have been invoked. + void entityId + expect(cleanupFn).not.toHaveBeenCalled() + expect(world.allNodes()).toHaveLength(1) + }) + + it('multiple nodes — each removal triggers only its own callback', () => { + const world = createHarnessWorld() + const app = createMiniComfyApp(world) + + const cbA = vi.fn() + const cbB = vi.fn() + const idA = app.graph.add({ type: 'NodeA' }) + const idB = app.graph.add({ type: 'NodeB' }) + + // Remove only A. + app.graph.remove(idA) + cbA() // simulate LiteGraph calling onRemoved on the removed node only + + expect(cbA).toHaveBeenCalledOnce() + expect(cbB).not.toHaveBeenCalled() + expect(world.findNode(idA)).toBeUndefined() + expect(world.findNode(idB)).toBeDefined() + }) + + it('graph.clear() removes all nodes from the World', () => { + const world = createHarnessWorld() + const app = createMiniComfyApp(world) + + app.graph.add({ type: 'NodeA' }) + app.graph.add({ type: 'NodeB' }) + app.graph.add({ type: 'NodeC' }) + expect(world.allNodes()).toHaveLength(3) + + world.clear() + expect(world.allNodes()).toHaveLength(0) + }) + }) + + describe('S2.N4 — evidence excerpt (loadEvidenceSnippet)', () => { + it('S2.N4 has at least one evidence excerpt in the snapshot', () => { + expect(countEvidenceExcerpts('S2.N4')).toBeGreaterThan(0) + }) + + it('S2.N4 excerpt contains onRemoved fingerprint', () => { + const snippet = loadEvidenceSnippet('S2.N4', 0) + expect(snippet.length).toBeGreaterThan(0) + expect(snippet).toMatch(/onRemoved/i) + }) + }) +}) + +// ── Phase B stubs — need eval sandbox + LiteGraph prototype wiring ─────────── + +describe('BC.02 v1 contract — node lifecycle: teardown [Phase B]', () => { + describe('S2.N4 — node.onRemoved', () => { + it.todo( + 'onRemoved is called exactly once when a node is removed from the graph via graph.remove(node)' + ) + it.todo( + 'onRemoved is called when a node is deleted via the canvas context-menu delete action' + ) + it.todo( + 'onRemoved is called for every node when the graph is cleared (graph.clear())' + ) + it.todo( + 'DOM widgets appended by the extension are accessible for cleanup inside onRemoved (not yet garbage-collected)' + ) + it.todo( + 'setInterval / requestAnimationFrame handles stored on the node instance can be cancelled inside onRemoved' + ) + it.todo( + 'MutationObserver and ResizeObserver instances stored on the node can be disconnected inside onRemoved' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-02.v2.test.ts b/src/extension-api-v2/__tests__/bc-02.v2.test.ts new file mode 100644 index 0000000000..cfd985699a --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-02.v2.test.ts @@ -0,0 +1,38 @@ +// Category: BC.02 — Node lifecycle: teardown +// DB cross-ref: S2.N4 +// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137 +// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: defineNodeExtension({ onRemoved(handle) { ... } }) +// Note: v2 onRemoved runs inside the NodeHandle scope; extension-owned resources +// registered via handle APIs are auto-disposed before onRemoved fires. + +import { describe, it } from 'vitest' + +describe('BC.02 v2 contract — node lifecycle: teardown', () => { + describe('onRemoved(handle) — cleanup hook', () => { + it.todo( + 'onRemoved is called exactly once per node instance when the node is removed from the graph' + ) + it.todo( + 'onRemoved receives the same NodeHandle that was passed to nodeCreated for the same instance' + ) + it.todo( + 'NodeHandle.getState() is still readable inside onRemoved (state not yet cleared)' + ) + it.todo( + 'onRemoved is called for every node when the graph is cleared, in no guaranteed order' + ) + }) + + describe('auto-disposal of handle-registered resources', () => { + it.todo( + 'DOM widgets registered via NodeHandle.addDOMWidget() are removed from the DOM before onRemoved fires' + ) + it.todo( + 'cleanup functions registered via NodeHandle.onDispose() are invoked before onRemoved fires' + ) + it.todo( + 'extension can still perform additional teardown in onRemoved after auto-disposal completes' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-03.migration.test.ts b/src/extension-api-v2/__tests__/bc-03.migration.test.ts new file mode 100644 index 0000000000..4880df9c5b --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-03.migration.test.ts @@ -0,0 +1,36 @@ +// Category: BC.03 — Node lifecycle: hydration from saved workflows +// DB cross-ref: S1.H1, S2.N7 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships +// Migration: v1 node.onConfigure / beforeRegisterNodeDef → v2 defineNodeExtension({ onConfigure(handle, data) }) + +import { describe, it } from 'vitest' + +describe('BC.03 migration — node lifecycle: hydration from saved workflows', () => { + describe('onConfigure parity (S2.N7)', () => { + it.todo( + 'v1 node.onConfigure and v2 onConfigure are both called exactly once per node during workflow load' + ) + it.todo( + 'the serialized data object received in v2 onConfigure contains the same fields as in v1' + ) + it.todo( + 'custom property restoration logic written for v1 onConfigure is portable to v2 with only handle substitution' + ) + }) + + describe('beforeRegisterNodeDef hydration guard → type-scoped extension (S1.H1)', () => { + it.todo( + 'prototype-level onConfigure injected via v1 beforeRegisterNodeDef produces the same hydration result as a v2 type-scoped onConfigure' + ) + it.todo( + 'v2 type-scoped onConfigure does not fire for node types not listed in types:, matching v1 guard behavior' + ) + }) + + describe('fresh-creation exclusion invariant', () => { + it.todo( + 'neither v1 nor v2 onConfigure fires when a node is created fresh (not from a saved workflow)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-03.v1.test.ts b/src/extension-api-v2/__tests__/bc-03.v1.test.ts new file mode 100644 index 0000000000..ebe4844649 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-03.v1.test.ts @@ -0,0 +1,39 @@ +// Category: BC.03 — Node lifecycle: hydration from saved workflows +// DB cross-ref: S1.H1, S2.N7 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// Surface: S1.H1 = beforeRegisterNodeDef (used for hydration guards), S2.N7 = node.onConfigure +// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships +// v1 contract: S1.H1 = beforeRegisterNodeDef guard; S2.N7 = node.onConfigure = function(data) { ... } +// Note: loadedGraphNode hook exists in LiteGraph but is effectively unused in ComfyUI — +// onConfigure is the de-facto hydration surface. + +import { describe, it } from 'vitest' + +describe('BC.03 v1 contract — node lifecycle: hydration from saved workflows', () => { + describe('S2.N7 — node.onConfigure', () => { + it.todo( + 'onConfigure is called when a saved workflow is loaded and the node is rehydrated from serialized data' + ) + it.todo( + 'onConfigure receives the raw serialized node object (data) as its first argument' + ) + it.todo( + 'onConfigure is NOT called on freshly created nodes (only on deserialization)' + ) + it.todo( + 'widget values written to data inside a prior session are accessible via data.widgets_values in onConfigure' + ) + it.todo( + 'extensions can restore custom properties stored in data.properties inside onConfigure' + ) + }) + + describe('S1.H1 — beforeRegisterNodeDef hydration guard', () => { + it.todo( + 'beforeRegisterNodeDef can inject a custom onConfigure override on the node prototype before any instance is created' + ) + it.todo( + 'prototype-level onConfigure injected in beforeRegisterNodeDef is invoked for all instances during workflow load' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-03.v2.test.ts b/src/extension-api-v2/__tests__/bc-03.v2.test.ts new file mode 100644 index 0000000000..a207e3a14a --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-03.v2.test.ts @@ -0,0 +1,36 @@ +// Category: BC.03 — Node lifecycle: hydration from saved workflows +// DB cross-ref: S1.H1, S2.N7 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: defineNodeExtension({ onConfigure(handle, data) { ... } }) + +import { describe, it } from 'vitest' + +describe('BC.03 v2 contract — node lifecycle: hydration from saved workflows', () => { + describe('onConfigure(handle, data) — workflow hydration hook', () => { + it.todo( + 'onConfigure is called when a node is rehydrated from a saved workflow and NOT on fresh node creation' + ) + it.todo( + 'onConfigure receives the NodeHandle as first argument and the raw serialized node object as second argument' + ) + it.todo( + 'data passed to onConfigure contains widgets_values from the saved workflow' + ) + it.todo( + 'data passed to onConfigure contains properties from the saved workflow' + ) + it.todo( + 'state written to NodeHandle inside onConfigure is readable in all subsequent hook calls for that instance' + ) + }) + + describe('ordering and idempotency guarantees', () => { + it.todo( + 'onConfigure fires after nodeCreated for the same instance during workflow load' + ) + it.todo( + 'onConfigure is not called a second time if the same node receives a re-configure (idempotent load)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-04.migration.test.ts b/src/extension-api-v2/__tests__/bc-04.migration.test.ts new file mode 100644 index 0000000000..1b4ce440d2 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-04.migration.test.ts @@ -0,0 +1,45 @@ +// Category: BC.04 — Node interaction: pointer, selection, resize +// DB cross-ref: S2.N10, S2.N17, S2.N19 +// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202 +// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships +// Migration: v1 node.onMouseDown/onSelected/onResize → v2 handle.on('mousedown'|'selected'|'resize', ...) + +import { describe, it } from 'vitest' + +describe('BC.04 migration — node interaction: pointer, selection, resize', () => { + describe('mousedown parity (S2.N10)', () => { + it.todo( + 'v1 node.onMouseDown and v2 handle.on("mousedown") are both invoked for the same pointer-down events' + ) + it.todo( + 'propagation-stop by returning true in v1 is equivalent to event.stopPropagation() in v2 handler' + ) + it.todo( + 'local coordinates passed to v1 onMouseDown match the x/y in the v2 event object for the same input' + ) + }) + + describe('selection parity (S2.N17)', () => { + it.todo( + 'v1 node.onSelected and v2 handle.on("selected") are both invoked when the node is selected' + ) + it.todo( + 'v2 introduces an explicit deselected event absent in v1; migration must add deselected handler for cleanup that relied on onSelected re-fire' + ) + }) + + describe('resize parity (S2.N19)', () => { + it.todo( + 'v1 node.onResize([w,h]) and v2 handle.on("resize", { width, height }) convey the same dimensions for the same resize action' + ) + it.todo( + 'computeSize overrides that triggered onResize in v1 still trigger the resize event in v2' + ) + }) + + describe('listener lifetime', () => { + it.todo( + 'v1 listeners on removed nodes remain registered (leak); v2 handle.on() listeners are auto-removed on node removal' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-04.v1.test.ts b/src/extension-api-v2/__tests__/bc-04.v1.test.ts new file mode 100644 index 0000000000..47143020c0 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-04.v1.test.ts @@ -0,0 +1,49 @@ +// Category: BC.04 — Node interaction: pointer, selection, resize +// DB cross-ref: S2.N10, S2.N17, S2.N19 +// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202 +// Surface: S2.N10 = node.onMouseDown, S2.N17 = node.onSelected, S2.N19 = node.onResize +// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.onMouseDown, node.onSelected, node.onResize prototype method assignments + +import { describe, it } from 'vitest' + +describe('BC.04 v1 contract — node interaction: pointer, selection, resize', () => { + describe('S2.N10 — node.onMouseDown', () => { + it.todo( + 'onMouseDown is called when a pointer-down event occurs within the node bounding box on the canvas' + ) + it.todo( + 'onMouseDown receives the MouseEvent and the local [x, y] position within the node as arguments' + ) + it.todo( + 'returning true from onMouseDown stops propagation to LiteGraph default mouse handling' + ) + it.todo( + 'onMouseDown is NOT called when the pointer down is outside the node bounding box' + ) + }) + + describe('S2.N17 — node.onSelected', () => { + it.todo( + 'onSelected is called when the node transitions to selected state (single-click or box-select)' + ) + it.todo( + 'onSelected is called once per selection event even if the node was already selected' + ) + it.todo( + 'onSelected is not called when a different node is selected and this node is deselected' + ) + }) + + describe('S2.N19 — node.onResize', () => { + it.todo( + 'onResize is called after the node dimensions change (user drag-resize or programmatic setSize)' + ) + it.todo( + 'onResize receives the new [width, height] array as its argument' + ) + it.todo( + 'onResize is called after the node size is committed, not during the drag' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-04.v2.test.ts b/src/extension-api-v2/__tests__/bc-04.v2.test.ts new file mode 100644 index 0000000000..b9ce7ed22c --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-04.v2.test.ts @@ -0,0 +1,48 @@ +// Category: BC.04 — Node interaction: pointer, selection, resize +// DB cross-ref: S2.N10, S2.N17, S2.N19 +// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202 +// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: defineNodeExtension({ on('mousedown', ...), on('selected', ...), on('resize', ...) }) + +import { describe, it } from 'vitest' + +describe('BC.04 v2 contract — node interaction: pointer, selection, resize', () => { + describe('on(\"mousedown\", handler) — pointer events (S2.N10)', () => { + it.todo( + 'handle.on("mousedown", handler) registers a listener called when pointer-down occurs within the node bounding box' + ) + it.todo( + 'handler receives an event object with local x/y coordinates relative to the node origin' + ) + it.todo( + 'handler returning true stops propagation to LiteGraph default mouse handling' + ) + it.todo( + 'listener registered via handle.on() is automatically removed when the node is removed from the graph' + ) + }) + + describe('on(\"selected\", handler) — selection focus (S2.N17)', () => { + it.todo( + 'handle.on("selected", handler) is called when the node enters selected state' + ) + it.todo( + 'handle.on("deselected", handler) is called when the node exits selected state' + ) + it.todo( + 'selected and deselected events do not fire during programmatic selection with { silent: true } option' + ) + }) + + describe('on(\"resize\", handler) — resize feedback (S2.N19)', () => { + it.todo( + 'handle.on("resize", handler) is called after the node dimensions change' + ) + it.todo( + 'handler receives a { width, height } object matching the new node size' + ) + it.todo( + 'resize event fires for both user drag-resize and programmatic NodeHandle.setSize() calls' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-05.migration.test.ts b/src/extension-api-v2/__tests__/bc-05.migration.test.ts new file mode 100644 index 0000000000..d1f38640c8 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-05.migration.test.ts @@ -0,0 +1,39 @@ +// Category: BC.05 — Custom DOM widgets and node sizing +// DB cross-ref: S4.W2, S2.N11 +// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218 +// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships +// Migration: v1 node.addDOMWidget + node.computeSize → v2 NodeHandle.addDOMWidget + WidgetHandle.setHeight + +import { describe, it } from 'vitest' + +describe('BC.05 migration — custom DOM widgets and node sizing', () => { + describe('widget registration parity (S4.W2)', () => { + it.todo( + 'v1 node.addDOMWidget and v2 NodeHandle.addDOMWidget both result in the element being visible inside the node widget area' + ) + it.todo( + 'the widget is accessible by name in both v1 node.widgets and v2 NodeHandle.widgets after registration' + ) + it.todo( + 'v1 opts.getHeight() returning N produces the same reserved height as v2 addDOMWidget({ height: N })' + ) + }) + + describe('computeSize elimination (S2.N11)', () => { + it.todo( + 'v1 manual computeSize override is unnecessary in v2; equivalent height reservation is achieved via WidgetHandle.setHeight()' + ) + it.todo( + 'node rendered with v2 auto-computeSize integration has the same final dimensions as v1 with an equivalent manual computeSize override' + ) + }) + + describe('cleanup parity', () => { + it.todo( + 'v1 requires manual DOM removal in onRemoved; v2 auto-removes the widget element — both result in the element being absent after node removal' + ) + it.todo( + 'v2 auto-cleanup does not remove DOM elements that were not registered via addDOMWidget, matching v1 scoping' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-05.v1.test.ts b/src/extension-api-v2/__tests__/bc-05.v1.test.ts new file mode 100644 index 0000000000..c632156cd9 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-05.v1.test.ts @@ -0,0 +1,43 @@ +// Category: BC.05 — Custom DOM widgets and node sizing +// DB cross-ref: S4.W2, S2.N11 +// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218 +// Surface: S4.W2 = node.addDOMWidget, S2.N11 = node.computeSize override +// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.addDOMWidget(name, type, element, opts) + node.computeSize = function(out) { ... } + +import { describe, it } from 'vitest' + +describe('BC.05 v1 contract — custom DOM widgets and node sizing', () => { + describe('S4.W2 — node.addDOMWidget', () => { + it.todo( + 'addDOMWidget(name, type, element, opts) appends the provided DOM element inside the node widget area' + ) + it.todo( + 'widget registered via addDOMWidget is accessible via node.widgets array by the given name' + ) + it.todo( + 'addDOMWidget opts.getHeight() is called during layout to determine the widget reserved height' + ) + it.todo( + 'addDOMWidget opts.onDraw(ctx) callback is invoked during each canvas render pass' + ) + it.todo( + 'the DOM element is removed from the document when the node is removed via graph.remove()' + ) + }) + + describe('S2.N11 — node.computeSize override', () => { + it.todo( + 'assigning node.computeSize = function(out) { ... } overrides the default size calculation for the node' + ) + it.todo( + 'overridden computeSize is called by LiteGraph layout engine before rendering' + ) + it.todo( + 'computeSize can return a [width, height] pair that accounts for the DOM widget reserved height' + ) + it.todo( + 'computeSize override persists across graph load/reload if set in nodeCreated or beforeRegisterNodeDef' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-05.v2.test.ts b/src/extension-api-v2/__tests__/bc-05.v2.test.ts new file mode 100644 index 0000000000..4c89cd939a --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-05.v2.test.ts @@ -0,0 +1,39 @@ +// Category: BC.05 — Custom DOM widgets and node sizing +// DB cross-ref: S4.W2, S2.N11 +// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218 +// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: NodeHandle.addDOMWidget(opts) — auto-hooks computeSize via WidgetHandle geometry + +import { describe, it } from 'vitest' + +describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => { + describe('NodeHandle.addDOMWidget(opts) — widget registration', () => { + it.todo( + 'NodeHandle.addDOMWidget({ name, element }) appends the element inside the node widget area' + ) + it.todo( + 'addDOMWidget returns a WidgetHandle that exposes the registered widget for further configuration' + ) + it.todo( + 'widget registered via addDOMWidget is included in NodeHandle.widgets list under opts.name' + ) + it.todo( + 'addDOMWidget({ name, element, height }) reserves the specified height without requiring a manual computeSize override' + ) + it.todo( + 'the DOM element is removed from the document automatically when the node is removed (no manual cleanup)' + ) + }) + + describe('WidgetHandle geometry — auto-computeSize integration (S2.N11)', () => { + it.todo( + 'WidgetHandle.setHeight(px) updates the reserved height and triggers a node relayout without a manual computeSize call' + ) + it.todo( + 'when multiple DOM widgets are registered, the total node height accounts for all widget heights' + ) + it.todo( + 'calling WidgetHandle.setHeight() after initial mount correctly re-lays out the node on next render frame' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-06.migration.test.ts b/src/extension-api-v2/__tests__/bc-06.migration.test.ts new file mode 100644 index 0000000000..f6e122c067 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-06.migration.test.ts @@ -0,0 +1,40 @@ +// Category: BC.06 — Custom canvas drawing (per-node and canvas-level) +// DB cross-ref: S2.N9, S3.C1, S3.C2 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256 +// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships +// Migration: v1 node.onDrawForeground → v2 NodeHandle.onDraw (partial). +// S3.C1 / S3.C2 canvas-level overrides: no v2 migration path yet (D9 Phase C). + +import { describe, it } from 'vitest' + +describe('BC.06 migration — custom canvas drawing (per-node and canvas-level)', () => { + describe('per-node drawing migration (S2.N9)', () => { + it.todo( + 'v1 node.onDrawForeground and v2 NodeHandle.onDraw both produce visually equivalent output on the canvas for the same drawing operations' + ) + it.todo( + 'draw callback in v2 fires the same number of times per second as v1 onDrawForeground for a static scene' + ) + it.todo( + 'v2 DrawContext.ctx is the same CanvasRenderingContext2D state as v1 receives (same transform, same clip)' + ) + }) + + describe('auto-deregistration vs manual cleanup', () => { + it.todo( + 'v1 onDrawForeground continues to fire after node removal if the reference is not cleared (leak); v2 onDraw is auto-removed' + ) + it.todo( + 'v2 auto-deregistration on node removal does not affect onDraw callbacks registered for other nodes' + ) + }) + + describe('canvas-level override coexistence (S3.C1, S3.C2)', () => { + it.todo( + 'extensions that replace LGraphCanvas.prototype methods in v1 continue to function alongside v2 NodeHandle.onDraw registrations without conflict' + ) + it.todo( + 'processContextMenu replacement in v1 is not disrupted by extensions migrated to v2 per-node APIs' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-06.v1.test.ts b/src/extension-api-v2/__tests__/bc-06.v1.test.ts new file mode 100644 index 0000000000..5d19088376 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-06.v1.test.ts @@ -0,0 +1,55 @@ +// Category: BC.06 — Custom canvas drawing (per-node and canvas-level) +// DB cross-ref: S2.N9, S3.C1, S3.C2 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256 +// Surface: S2.N9 = node.onDrawForeground, S3.C1 = LGraphCanvas.prototype overrides, S3.C2 = ContextMenu replacement +// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.onDrawForeground(ctx, area), LGraphCanvas.prototype.processContextMenu = ..., +// LGraphCanvas.prototype.drawNodeShape = ... etc. +// v1_scope_note: Simon Tranter (COM-3668) vetoed canvas drawing overrides as "too hacky/specific". +// S3.C* patterns tracked for blast-radius / strangler-fig planning only. + +import { describe, it } from 'vitest' + +describe('BC.06 v1 contract — custom canvas drawing (per-node and canvas-level)', () => { + describe('S2.N9 — node.onDrawForeground', () => { + it.todo( + 'onDrawForeground(ctx, visibleArea) is called once per render frame for each visible node' + ) + it.todo( + 'ctx passed to onDrawForeground is the same CanvasRenderingContext2D used by LiteGraph for the node layer' + ) + it.todo( + 'drawing operations performed in onDrawForeground appear above the node body and below the selection highlight' + ) + it.todo( + 'onDrawForeground is NOT called for nodes outside the visible area (culled by LiteGraph)' + ) + it.todo( + 'canvas transform (scale, translate) is already applied when onDrawForeground fires — coordinates are in graph space' + ) + }) + + describe('S3.C1 — LGraphCanvas.prototype method overrides', () => { + it.todo( + 'assigning LGraphCanvas.prototype.drawNodeShape replaces the built-in node shape renderer for all nodes' + ) + it.todo( + 'prototype override affects all canvas instances sharing the same prototype (global side-effect)' + ) + it.todo( + 'two extensions both overriding the same LGraphCanvas.prototype method result in last-writer-wins behavior' + ) + }) + + describe('S3.C2 — ContextMenu global replacement', () => { + it.todo( + 'reassigning LGraphCanvas.prototype.processContextMenu replaces the context-menu handler for every right-click on the canvas' + ) + it.todo( + 'extensions replacing processContextMenu must call the original to preserve built-in menu items' + ) + it.todo( + 'replacing processContextMenu is the most destructive canvas-level override — absence of original call silently drops all built-in menu entries' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-06.v2.test.ts b/src/extension-api-v2/__tests__/bc-06.v2.test.ts new file mode 100644 index 0000000000..6a4cf1272e --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-06.v2.test.ts @@ -0,0 +1,41 @@ +// Category: BC.06 — Custom canvas drawing (per-node and canvas-level) +// DB cross-ref: S2.N9, S3.C1, S3.C2 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256 +// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: NodeHandle.onDraw(callback) for per-node drawing (S2.N9). +// Canvas-level overrides (S3.C1, S3.C2) are OUT OF v2 SCOPE — deferred to D9 Phase C. +// S3.C* stubs present for blast-radius tracking and strangler-fig planning. + +import { describe, it } from 'vitest' + +describe('BC.06 v2 contract — custom canvas drawing (per-node and canvas-level)', () => { + describe('NodeHandle.onDraw(callback) — per-node foreground drawing (S2.N9)', () => { + it.todo( + 'NodeHandle.onDraw(cb) registers cb to be called once per render frame while the node is visible' + ) + it.todo( + 'callback receives a DrawContext with ctx (CanvasRenderingContext2D) and area (bounding rect) arguments' + ) + it.todo( + 'drawing operations in the callback appear in the same layer as v1 onDrawForeground (above node body)' + ) + it.todo( + 'the canvas transform is pre-applied when the callback fires — coordinates are in graph space, matching v1 behavior' + ) + it.todo( + 'callback registered via NodeHandle.onDraw() is automatically deregistered when the node is removed' + ) + }) + + describe('canvas-level overrides — deferred (S3.C1, S3.C2)', () => { + it.todo( + '[D9 Phase C] v2 exposes no stable API for replacing LGraphCanvas.prototype.drawNodeShape — extensions using this pattern must remain on v1 shim' + ) + it.todo( + '[D9 Phase C] v2 exposes no stable API for replacing processContextMenu — context-menu customization is deferred to the ComfyUI menu extension point' + ) + it.todo( + '[D9 Phase C] blast-radius tracking: S3.C1 and S3.C2 overrides coexist with v2 per-node drawing without mutual interference' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-07.migration.test.ts b/src/extension-api-v2/__tests__/bc-07.migration.test.ts new file mode 100644 index 0000000000..09de5f7ee4 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-07.migration.test.ts @@ -0,0 +1,44 @@ +// Category: BC.07 — Connection observation, intercept, and veto +// DB cross-ref: S2.N3, S2.N12, S2.N13 +// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90 +// Migration: v1 prototype method assignment → v2 NodeHandle.on('connectInput'/'connectOutput'/'connectionChange') + +import { describe, it } from 'vitest' + +describe('BC.07 migration — connection observation, intercept, and veto', () => { + describe('onConnectionsChange → on(\'connectionChange\') (S2.N3)', () => { + it.todo( + 'v1 onConnectionsChange and v2 on(\'connectionChange\') both fire for the same link connect event with equivalent payload data' + ) + it.todo( + 'v2 connectionChange event fires at the same point in the link-wiring sequence as v1 onConnectionsChange' + ) + }) + + describe('onConnectInput → on(\'connectInput\') (S2.N12)', () => { + it.todo( + 'v1 onConnectInput returning false and v2 on(\'connectInput\') returning false both result in an unwired graph with no link object created' + ) + it.todo( + 'type coercion performed inside v1 onConnectInput produces the same wired slot type as equivalent mutation inside v2 on(\'connectInput\')' + ) + }) + + describe('onConnectOutput → on(\'connectOutput\') (S2.N13)', () => { + it.todo( + 'v1 onConnectOutput veto and v2 on(\'connectOutput\') veto both prevent connectionChange from firing on either endpoint node' + ) + it.todo( + 'v2 on(\'connectOutput\') listener receives equivalent data to v1 onConnectOutput arguments for the same connection attempt' + ) + }) + + describe('scope and cleanup', () => { + it.todo( + 'v1 prototype method persists after extension unregisters (no cleanup); v2 on() listeners are removed on scope dispose' + ) + it.todo( + 'v2 cleanup does not affect connection listeners registered by other extensions on the same node' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-07.v1.test.ts b/src/extension-api-v2/__tests__/bc-07.v1.test.ts new file mode 100644 index 0000000000..605d84616b --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-07.v1.test.ts @@ -0,0 +1,53 @@ +// Category: BC.07 — Connection observation, intercept, and veto +// DB cross-ref: S2.N3, S2.N12, S2.N13 +// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90 +// blast_radius: 5.46 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.onConnectInput(slot, type, link, node, fromSlot) +// node.onConnectOutput(slot, type, link, node, toSlot) +// node.onConnectionsChange(type, slot, connected, link, ioSlot) + +import { describe, it } from 'vitest' + +describe('BC.07 v1 contract — connection observation, intercept, and veto', () => { + describe('S2.N3 — onConnectionsChange: passive observation', () => { + it.todo( + 'onConnectionsChange is called on the node when any input or output link is connected or disconnected' + ) + it.todo( + 'onConnectionsChange receives type (INPUT=1/OUTPUT=2), slot index, connected boolean, link info, and ioSlot' + ) + it.todo( + 'onConnectionsChange fires after the link is already wired into the graph (link is present at call time)' + ) + it.todo( + 'onConnectionsChange fires for both the source node and the target node on a single link operation' + ) + }) + + describe('S2.N12 — onConnectInput: intercept and veto incoming connections', () => { + it.todo( + 'onConnectInput returning false vetoes the connection before it is wired' + ) + it.todo( + 'onConnectInput returning true (or undefined) allows the connection to proceed' + ) + it.todo( + 'onConnectInput receives slot index, incoming type, link object, source node, and source slot' + ) + it.todo( + 'onConnectInput can mutate the slot type to coerce an incompatible type before wiring' + ) + }) + + describe('S2.N13 — onConnectOutput: intercept and veto outgoing connections', () => { + it.todo( + 'onConnectOutput returning false vetoes the outgoing connection before it is wired' + ) + it.todo( + 'onConnectOutput receives slot index, outgoing type, link object, target node, and target slot' + ) + it.todo( + 'onConnectOutput veto does not trigger onConnectionsChange on either node' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-07.v2.test.ts b/src/extension-api-v2/__tests__/bc-07.v2.test.ts new file mode 100644 index 0000000000..9187489d58 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-07.v2.test.ts @@ -0,0 +1,51 @@ +// Category: BC.07 — Connection observation, intercept, and veto +// DB cross-ref: S2.N3, S2.N12, S2.N13 +// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90 +// blast_radius: 5.46 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: NodeHandle.on('connectInput', ...), on('connectOutput', ...), on('connectionChange', ...) + +import { describe, it } from 'vitest' + +describe('BC.07 v2 contract — connection observation, intercept, and veto', () => { + describe('on(\'connectionChange\', fn) — passive observation', () => { + it.todo( + 'NodeHandle.on(\'connectionChange\', fn) fires fn after any input or output link is connected or disconnected' + ) + it.todo( + 'connectionChange event payload includes type (\'input\'|\'output\'), slotIndex, connected boolean, and link info' + ) + it.todo( + 'multiple listeners registered via on(\'connectionChange\') are all invoked in registration order' + ) + it.todo( + 'listener registered with on() is removed when the extension scope is disposed' + ) + }) + + describe('on(\'connectInput\', fn) — intercept and veto incoming connections', () => { + it.todo( + 'fn returning false from on(\'connectInput\') vetoes the connection; graph remains unwired' + ) + it.todo( + 'fn returning true or undefined from on(\'connectInput\') allows the connection to proceed' + ) + it.todo( + 'connectInput event payload includes slotIndex, type, link, sourceHandle, and sourceSlot' + ) + it.todo( + 'fn can mutate event.type to coerce a type mismatch before the connection is wired' + ) + }) + + describe('on(\'connectOutput\', fn) — intercept and veto outgoing connections', () => { + it.todo( + 'fn returning false from on(\'connectOutput\') vetoes the outgoing connection; connectionChange does not fire' + ) + it.todo( + 'connectOutput event payload includes slotIndex, type, link, targetHandle, and targetSlot' + ) + it.todo( + 'veto from connectOutput does not affect other registered connectOutput listeners on the same node' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-08.migration.test.ts b/src/extension-api-v2/__tests__/bc-08.migration.test.ts new file mode 100644 index 0000000000..fe43d93c35 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-08.migration.test.ts @@ -0,0 +1,38 @@ +// Category: BC.08 — Programmatic linking +// DB cross-ref: S10.D2 +// Exemplar: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138 +// Migration: v1 node.connect/disconnectInput → v2 NodeHandle.connect/disconnectInput (typed handles) + +import { describe, it } from 'vitest' + +describe('BC.08 migration — programmatic linking', () => { + describe('connect() equivalence', () => { + it.todo( + 'v1 node.connect(srcSlot, targetNode, dstSlot) and v2 NodeHandle.connect(srcSlot, targetHandle, dstSlot) produce identical graph link state' + ) + it.todo( + 'link id returned by v2 connect() matches the id on the underlying LGraph link created by an equivalent v1 call' + ) + it.todo( + 'v2 connect() with a type-incompatible pair raises a typed error; v1 returns null — callers must handle both forms during migration' + ) + }) + + describe('disconnectInput() equivalence', () => { + it.todo( + 'v1 node.disconnectInput(slot) and v2 NodeHandle.disconnectInput(slotIndex) both leave the graph with no link on that slot' + ) + it.todo( + 'onConnectionsChange (v1) and on(\'connectionChange\') (v2) both fire for the same disconnect operation with equivalent payload data' + ) + }) + + describe('handle vs. raw node reference', () => { + it.todo( + 'v2 NodeHandle.connect() accepts a NodeHandle for targetHandle; passing a raw LGraphNode instance throws a deprecation error' + ) + it.todo( + 'NodeHandle obtained from v2 nodeCreated correctly wraps the same node that v1 connect() would operate on' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-08.v1.test.ts b/src/extension-api-v2/__tests__/bc-08.v1.test.ts new file mode 100644 index 0000000000..da0b2d2bb6 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-08.v1.test.ts @@ -0,0 +1,40 @@ +// Category: BC.08 — Programmatic linking +// DB cross-ref: S10.D2 +// Exemplar: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138 +// blast_radius: 5.99 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.connect(srcSlot, targetNode, dstSlot) +// node.disconnectInput(slot) + +import { describe, it } from 'vitest' + +describe('BC.08 v1 contract — programmatic linking', () => { + describe('S10.D2 — node.connect(srcSlot, targetNode, dstSlot)', () => { + it.todo( + 'node.connect(srcSlot, targetNode, dstSlot) creates a link between the source output slot and the target input slot' + ) + it.todo( + 'connect() returns the newly created link object with a stable numeric id' + ) + it.todo( + 'connect() on an already-occupied input slot replaces the existing link without leaving a dangling reference' + ) + it.todo( + 'connect() with a type-incompatible slot pair is rejected and returns null without modifying the graph' + ) + it.todo( + 'onConnectionsChange fires on both the source and target node after a successful connect() call' + ) + }) + + describe('S10.D2 — node.disconnectInput(slot)', () => { + it.todo( + 'node.disconnectInput(slot) removes the link on the specified input slot and updates both endpoint nodes' + ) + it.todo( + 'disconnectInput() on an empty slot is a no-op and does not throw' + ) + it.todo( + 'onConnectionsChange fires on both the source and target node after disconnectInput() removes a link' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-08.v2.test.ts b/src/extension-api-v2/__tests__/bc-08.v2.test.ts new file mode 100644 index 0000000000..ea3dc9ad43 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-08.v2.test.ts @@ -0,0 +1,39 @@ +// Category: BC.08 — Programmatic linking +// DB cross-ref: S10.D2 +// Exemplar: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138 +// blast_radius: 5.99 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: NodeHandle.connect(slotIndex, targetHandle, dstSlot) — same semantics, typed handles + +import { describe, it } from 'vitest' + +describe('BC.08 v2 contract — programmatic linking', () => { + describe('NodeHandle.connect(slotIndex, targetHandle, dstSlot) — create links', () => { + it.todo( + 'NodeHandle.connect(slotIndex, targetHandle, dstSlot) creates a link between the source output slot and the target input slot' + ) + it.todo( + 'connect() returns a LinkHandle with a stable id that matches the underlying graph link id' + ) + it.todo( + 'connect() on an already-occupied input slot replaces the existing link and the old LinkHandle becomes invalid' + ) + it.todo( + 'connect() with a type-incompatible slot pair throws a typed error and leaves the graph unchanged' + ) + it.todo( + 'on(\'connectionChange\') fires on both NodeHandles after a successful connect() call' + ) + }) + + describe('NodeHandle.disconnectInput(slotIndex) — remove links', () => { + it.todo( + 'NodeHandle.disconnectInput(slotIndex) removes the link on the specified input slot and the returned LinkHandle becomes invalid' + ) + it.todo( + 'disconnectInput() on an empty slot is a no-op and does not throw' + ) + it.todo( + 'on(\'connectionChange\') fires on both source and target NodeHandles after disconnectInput() removes a link' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-09.migration.test.ts b/src/extension-api-v2/__tests__/bc-09.migration.test.ts new file mode 100644 index 0000000000..9ca79b1f21 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-09.migration.test.ts @@ -0,0 +1,42 @@ +// Category: BC.09 — Dynamic slot and output mutation +// DB cross-ref: S10.D1, S10.D3, S15.OS1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121 +// Migration: v1 positional addInput/removeInput/addOutput/removeOutput + manual setSize +// → v2 name-based NodeHandle.addInput/removeInput/addOutput/removeOutput with auto-reflow + +import { describe, it } from 'vitest' + +describe('BC.09 migration — dynamic slot and output mutation', () => { + describe('addInput / addOutput equivalence (S10.D1, S10.D3)', () => { + it.todo( + 'v1 node.addInput(name, type) and v2 NodeHandle.addInput({ name, type }) both result in an equivalent slot appended to the node' + ) + it.todo( + 'v1 node.addOutput(name, type) and v2 NodeHandle.addOutput({ name, type }) both result in an equivalent output slot with a matching type' + ) + it.todo( + 'slot added via v2 addInput() is accessible at the same index position as an equivalent v1 addInput() call (append-only ordering preserved)' + ) + }) + + describe('removeInput / removeOutput equivalence', () => { + it.todo( + 'v1 node.removeInput(slotIndex) and v2 NodeHandle.removeInput(name) both remove the slot and detach active links; remaining slots have consistent indices' + ) + it.todo( + 'v2 removeInput(name) correctly identifies the slot when multiple slots exist, matching by name not by position' + ) + }) + + describe('reflow: manual setSize vs. automatic (S15.OS1)', () => { + it.todo( + 'v1 addInput() + setSize([...computeSize()]) and v2 addInput() auto-reflow both produce a node with equal or greater height to display the new slot' + ) + it.todo( + 'v2 auto-reflow after removeOutput() shrinks the node to the same height as a v1 removeOutput() + manual setSize() sequence' + ) + it.todo( + 'omitting setSize after a v1 addInput() call causes slot overlap; v2 auto-reflow never produces this condition' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-09.v1.test.ts b/src/extension-api-v2/__tests__/bc-09.v1.test.ts new file mode 100644 index 0000000000..ff1ff32286 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-09.v1.test.ts @@ -0,0 +1,50 @@ +// Category: BC.09 — Dynamic slot and output mutation +// DB cross-ref: S10.D1, S10.D3, S15.OS1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121 +// blast_radius: 6.03 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.addInput(name, type), node.removeInput(slot) +// node.addOutput(name, type), node.removeOutput(slot) +// node.setSize([w, h]) + +import { describe, it } from 'vitest' + +describe('BC.09 v1 contract — dynamic slot and output mutation', () => { + describe('S10.D1 — addInput / removeInput', () => { + it.todo( + 'node.addInput(name, type) appends a new input slot to node.inputs and increments node.inputs.length' + ) + it.todo( + 'node.removeInput(slot) removes the slot at the given index and shifts subsequent slots down by one' + ) + it.todo( + 'removing an input slot that has an active link also removes the corresponding link from the graph' + ) + it.todo( + 'addInput with a duplicate name appends a second slot without error (v1 allows duplicates)' + ) + }) + + describe('S10.D3 — addOutput / removeOutput', () => { + it.todo( + 'node.addOutput(name, type) appends a new output slot to node.outputs and increments node.outputs.length' + ) + it.todo( + 'node.removeOutput(slot) removes the output slot and detaches all outgoing links on that slot' + ) + it.todo( + 'removing an output slot does not affect links on other output slots of the same node' + ) + }) + + describe('S15.OS1 — computeSize / setSize reflow', () => { + it.todo( + 'node.setSize([w, h]) updates node.size to the provided dimensions immediately' + ) + it.todo( + 'addInput/addOutput followed by node.setSize([...node.computeSize()]) produces a node tall enough to display all slots without overlap' + ) + it.todo( + 'setSize does not trigger a canvas redraw synchronously; redraw occurs on the next animation frame' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-09.v2.test.ts b/src/extension-api-v2/__tests__/bc-09.v2.test.ts new file mode 100644 index 0000000000..6f9bd4c278 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-09.v2.test.ts @@ -0,0 +1,50 @@ +// Category: BC.09 — Dynamic slot and output mutation +// DB cross-ref: S10.D1, S10.D3, S15.OS1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121 +// blast_radius: 6.03 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: NodeHandle.addInput(opts), NodeHandle.removeInput(name) +// NodeHandle.addOutput(opts), NodeHandle.removeOutput(name) +// reflow handled automatically — no manual setSize required + +import { describe, it } from 'vitest' + +describe('BC.09 v2 contract — dynamic slot and output mutation', () => { + describe('NodeHandle.addInput / removeInput (S10.D1)', () => { + it.todo( + 'NodeHandle.addInput({ name, type }) appends a new input slot and returns a SlotHandle with a stable name-based identity' + ) + it.todo( + 'NodeHandle.removeInput(name) removes the named input slot and detaches any active link on that slot' + ) + it.todo( + 'removeInput(name) on a non-existent slot name throws a typed SlotNotFoundError' + ) + it.todo( + 'addInput with a duplicate name throws a DuplicateSlotError (v2 enforces uniqueness unlike v1)' + ) + }) + + describe('NodeHandle.addOutput / removeOutput (S10.D3)', () => { + it.todo( + 'NodeHandle.addOutput({ name, type }) appends a new output slot and returns a SlotHandle' + ) + it.todo( + 'NodeHandle.removeOutput(name) removes the output slot and detaches all outgoing links on that slot' + ) + it.todo( + 'removeOutput does not affect slots or links on other output slots of the same node' + ) + }) + + describe('automatic reflow (replaces S15.OS1 manual setSize)', () => { + it.todo( + 'after addInput() or addOutput() the node size is automatically reflowed to fit all slots without a manual setSize call' + ) + it.todo( + 'after removeInput() or removeOutput() the node size is automatically shrunk to remove the vacated slot space' + ) + it.todo( + 'automatic reflow does not trigger a synchronous canvas redraw; redraw occurs on the next animation frame' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-10.migration.test.ts b/src/extension-api-v2/__tests__/bc-10.migration.test.ts new file mode 100644 index 0000000000..c7db05b7bb --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-10.migration.test.ts @@ -0,0 +1,39 @@ +// Category: BC.10 — Widget value subscription +// DB cross-ref: S4.W1, S2.N14 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317 +// Migration: v1 widget.callback chain-patching / node.onWidgetChanged +// → v2 WidgetHandle.on('change') / NodeHandle.on('widgetChanged') + +import { describe, it } from 'vitest' + +describe('BC.10 migration — widget value subscription', () => { + describe('widget.callback → WidgetHandle.on(\'change\') (S4.W1)', () => { + it.todo( + 'v1 widget.callback and v2 WidgetHandle.on(\'change\') both fire with the new value for the same user interaction' + ) + it.todo( + 'v2 on(\'change\') fires at the same point in the event sequence as the last v1 callback in the chain' + ) + it.todo( + 'v1 chain-patching does not compose with v2 on(\'change\'): each operates independently; both fire for the same change event' + ) + }) + + describe('node.onWidgetChanged → NodeHandle.on(\'widgetChanged\') (S2.N14)', () => { + it.todo( + 'v1 node.onWidgetChanged and v2 NodeHandle.on(\'widgetChanged\') both receive equivalent widget name, value, and oldValue for the same change' + ) + it.todo( + 'v2 widgetChanged payload includes a WidgetHandle reference instead of a raw widget object; WidgetHandle.name matches the widget name' + ) + }) + + describe('ordering and isolation', () => { + it.todo( + 'v2 on(\'change\') listeners from different extensions on the same widget all fire without one suppressing another' + ) + it.todo( + 'disposing one extension scope removes only its own on(\'change\') listeners; other extensions\' listeners continue to fire' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-10.v1.test.ts b/src/extension-api-v2/__tests__/bc-10.v1.test.ts new file mode 100644 index 0000000000..e502b43b31 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-10.v1.test.ts @@ -0,0 +1,37 @@ +// Category: BC.10 — Widget value subscription +// DB cross-ref: S4.W1, S2.N14 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317 +// blast_radius: 5.09 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v1 contract: widget.callback = function(value, ...) { ... } (chain-patching) +// node.onWidgetChanged = function(name, value, ...) { ... } + +import { describe, it } from 'vitest' + +describe('BC.10 v1 contract — widget value subscription', () => { + describe('S4.W1 — widget.callback chain-patching', () => { + it.todo( + 'assigning widget.callback invokes the function with the new value whenever the widget is interacted with' + ) + it.todo( + 'chain-patching preserves the previous callback: saving the old reference and calling it at the end of the new function' + ) + it.todo( + 'widget.callback receives (value, app, node, pos, event) in that argument order' + ) + it.todo( + 'if multiple extensions chain-patch widget.callback, all callbacks are invoked in stack order (last-patched first)' + ) + }) + + describe('S2.N14 — node.onWidgetChanged', () => { + it.todo( + 'node.onWidgetChanged is called once per widget value change with the widget name, new value, old value, and widget reference' + ) + it.todo( + 'onWidgetChanged fires for every widget on the node, not only those with an explicit callback' + ) + it.todo( + 'onWidgetChanged fires after widget.callback has been invoked for the same change event' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-10.v2.test.ts b/src/extension-api-v2/__tests__/bc-10.v2.test.ts new file mode 100644 index 0000000000..93b2568832 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-10.v2.test.ts @@ -0,0 +1,36 @@ +// Category: BC.10 — Widget value subscription +// DB cross-ref: S4.W1, S2.N14 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317 +// blast_radius: 5.09 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: WidgetHandle.on('change', fn), NodeHandle.on('widgetChanged', fn) + +import { describe, it } from 'vitest' + +describe('BC.10 v2 contract — widget value subscription', () => { + describe('WidgetHandle.on(\'change\', fn) — per-widget subscription (S4.W1)', () => { + it.todo( + 'WidgetHandle.on(\'change\', fn) fires fn with (newValue, oldValue) whenever the widget value changes' + ) + it.todo( + 'multiple on(\'change\') listeners on the same WidgetHandle are all invoked in registration order' + ) + it.todo( + 'on(\'change\') listener is removed when the extension scope is disposed; subsequent changes do not invoke the stale listener' + ) + it.todo( + 'on(\'change\') listener can call event.preventDefault() to block the value write (unlike v1 callback which cannot veto)' + ) + }) + + describe('NodeHandle.on(\'widgetChanged\', fn) — node-level subscription (S2.N14)', () => { + it.todo( + 'NodeHandle.on(\'widgetChanged\', fn) fires fn for any widget value change on the node, with payload { name, value, oldValue, widget }' + ) + it.todo( + 'widgetChanged fires after all per-widget on(\'change\') listeners have been invoked for the same change event' + ) + it.todo( + 'widgetChanged fires for every widget on the node regardless of whether the widget has individual on(\'change\') listeners' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-11.migration.test.ts b/src/extension-api-v2/__tests__/bc-11.migration.test.ts new file mode 100644 index 0000000000..6829039dad --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-11.migration.test.ts @@ -0,0 +1,45 @@ +// Category: BC.11 — Widget imperative state writes +// DB cross-ref: S4.W4, S4.W5, S2.N16 +// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9 +// Migration: v1 direct property mutation (widget.value, widget.options.values, node.widgets.push/splice) +// → v2 WidgetHandle.setValue / setOptions / NodeHandle.addWidget / removeWidget + +import { describe, it } from 'vitest' + +describe('BC.11 migration — widget imperative state writes', () => { + describe('widget.value → WidgetHandle.setValue() (S4.W4)', () => { + it.todo( + 'v1 widget.value = v and v2 WidgetHandle.setValue(v) both result in the same displayed value on the canvas' + ) + it.todo( + 'v1 direct assignment does not fire on(\'change\') listeners; v2 setValue() does — callers must not assume silence' + ) + it.todo( + 'v2 setValue() raises InvalidValueError for out-of-range COMBO values; v1 assignment silently accepts them' + ) + }) + + describe('widget.options.values → WidgetHandle.setOptions() (S4.W5)', () => { + it.todo( + 'v1 widget.options.values = [...] and v2 WidgetHandle.setOptions({ values: [...] }) both replace the COMBO option list' + ) + it.todo( + 'v1 does not auto-reset stale current value; v2 setOptions() does — migration callers must handle the resulting on(\'change\') event' + ) + }) + + describe('node.widgets.push/splice → NodeHandle.addWidget/removeWidget (S2.N16)', () => { + it.todo( + 'v1 node.widgets.push(w) and v2 NodeHandle.addWidget(opts) both result in the widget being present in the node\'s widget list after the call' + ) + it.todo( + 'v1 splice causes widgets_values positional drift; v2 addWidget uses named-map and produces no drift even when inserted mid-list' + ) + it.todo( + 'v1 push requires a manual setSize reflow; v2 addWidget performs it automatically — do not double-reflow when migrating' + ) + it.todo( + 'v2 removeWidget(name) correctly finds the widget by name regardless of its position in the list; v1 splice requires the caller to track the index' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-11.v1.test.ts b/src/extension-api-v2/__tests__/bc-11.v1.test.ts new file mode 100644 index 0000000000..2af4991687 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-11.v1.test.ts @@ -0,0 +1,51 @@ +// Category: BC.11 — Widget imperative state writes +// DB cross-ref: S4.W4, S4.W5, S2.N16 +// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9 +// blast_radius: 5.81 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v1 contract: widget.value = newVal +// widget.options.values = [...] +// node.widgets.splice(i, 0, w) +// node.widgets.push(w) + +import { describe, it } from 'vitest' + +describe('BC.11 v1 contract — widget imperative state writes', () => { + describe('S4.W4 — widget.value direct assignment', () => { + it.todo( + 'assigning widget.value = newVal updates the displayed value on the next canvas redraw without triggering widget.callback' + ) + it.todo( + 'widget.value assignment to a value outside the COMBO options list does not throw but may display an invalid state' + ) + it.todo( + 'reading widget.value immediately after assignment returns the assigned value' + ) + }) + + describe('S4.W5 — widget.options.values mutation (COMBO options)', () => { + it.todo( + 'assigning widget.options.values = [...] replaces the COMBO dropdown options on the next canvas redraw' + ) + it.todo( + 'if the current widget.value is absent from the new options list, the widget continues to display the stale value (no auto-reset in v1)' + ) + it.todo( + 'widget.options.values mutation does not fire widget.callback' + ) + }) + + describe('S2.N16 — node.widgets array mutation (insert / push)', () => { + it.todo( + 'node.widgets.push(widget) appends the widget to the node\'s widget list and it renders on the next canvas redraw' + ) + it.todo( + 'node.widgets.splice(i, 0, widget) inserts a widget at position i and shifts subsequent widgets\' positional indices' + ) + it.todo( + 'inserting a widget via splice causes widgets_values positional drift if not followed by a node size reflow' + ) + it.todo( + 'node.widgets.push does not update node.size; calling setSize([...computeSize()]) is required to avoid slot overlap' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-11.v2.test.ts b/src/extension-api-v2/__tests__/bc-11.v2.test.ts new file mode 100644 index 0000000000..8390bc9164 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-11.v2.test.ts @@ -0,0 +1,52 @@ +// Category: BC.11 — Widget imperative state writes +// DB cross-ref: S4.W4, S4.W5, S2.N16 +// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9 +// blast_radius: 5.81 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: WidgetHandle.setValue(v), WidgetHandle.setOptions({ values: [...] }) +// NodeHandle.addWidget(opts), NodeHandle.removeWidget(name) + +import { describe, it } from 'vitest' + +describe('BC.11 v2 contract — widget imperative state writes', () => { + describe('WidgetHandle.setValue(v) — controlled value write (S4.W4)', () => { + it.todo( + 'WidgetHandle.setValue(v) updates the widget\'s current value and triggers a reactive update visible on the next canvas frame' + ) + it.todo( + 'setValue() fires the on(\'change\') listeners with (newValue, oldValue) in the same tick' + ) + it.todo( + 'setValue() with a value outside the COMBO options list throws a typed InvalidValueError' + ) + it.todo( + 'reading WidgetHandle.value immediately after setValue() returns the new value' + ) + }) + + describe('WidgetHandle.setOptions({ values }) — COMBO option replacement (S4.W5)', () => { + it.todo( + 'WidgetHandle.setOptions({ values: [...] }) replaces the COMBO options and triggers a reactive update' + ) + it.todo( + 'if the current value is absent from the new options list, setOptions() resets the value to options[0] automatically' + ) + it.todo( + 'setOptions() fires on(\'change\') only if the current value was reset due to option list change' + ) + }) + + describe('NodeHandle.addWidget / removeWidget — managed widget list mutation (S2.N16)', () => { + it.todo( + 'NodeHandle.addWidget(opts) appends a widget, auto-reflowing node size and updating the named widgets_values map' + ) + it.todo( + 'NodeHandle.removeWidget(name) removes the named widget, auto-reflowing node size and removing the entry from widgets_values' + ) + it.todo( + 'addWidget does not cause widgets_values positional drift because v2 uses a named map rather than a positional array' + ) + it.todo( + 'removeWidget(name) on a non-existent widget name throws a typed WidgetNotFoundError' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-12.migration.test.ts b/src/extension-api-v2/__tests__/bc-12.migration.test.ts new file mode 100644 index 0000000000..4e943c90ae --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-12.migration.test.ts @@ -0,0 +1,41 @@ +// Category: BC.12 — Per-widget serialization transform +// DB cross-ref: S4.W3 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70 +// Migration: v1 widget.serializeValue positional index → v2 WidgetHandle.on('serialize') / setSerializeValue name-based + +import { describe, it } from 'vitest' + +describe('BC.12 migration — per-widget serialization transform', () => { + describe('serializeValue → on(\'serialize\') round-trip equivalence', () => { + it.todo( + 'a v1 widget.serializeValue that returns a transformed value and a v2 on(\'serialize\') returning the same transformation produce identical output in the serialized workflow JSON' + ) + it.todo( + 'v1 serializeValue receives a positional index; v2 on(\'serialize\') does not — callers relying on the index for slot lookup must migrate to name-based lookup' + ) + it.todo( + 'async transforms: both v1 serializeValue and v2 on(\'serialize\') are awaited by graphToPrompt() before the workflow is finalized' + ) + }) + + describe('serialize===false widget compat', () => { + it.todo( + 'v1 positional index for a widget after control_after_generate is offset by 1 relative to the backend prompt; v2 named-map has no such offset' + ) + it.todo( + 'migrate: v1 code that hard-codes an index offset for serialize===false slots must be rewritten to use WidgetHandle identity by name in v2' + ) + it.todo( + 'widgets_values_named round-trip: a workflow serialized under v2 with an on(\'serialize\') transform deserializes to the same widget values as the equivalent v1 serializeValue workflow' + ) + }) + + describe('identity stability', () => { + it.todo( + 'v2 WidgetHandle identity is stable after node.widgets reordering; v1 serializeValue index changes if widgets are reordered — this is the primary reason to migrate' + ) + it.todo( + 'setSerializeValue(fn) called twice replaces the first registration; widget.serializeValue overwrites also replace — both v1 and v2 are last-write-wins' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-12.v1.test.ts b/src/extension-api-v2/__tests__/bc-12.v1.test.ts new file mode 100644 index 0000000000..b677d9caaf --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-12.v1.test.ts @@ -0,0 +1,39 @@ +// Category: BC.12 — Per-widget serialization transform +// DB cross-ref: S4.W3 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70 +// blast_radius: 5.58 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v1 contract: widget.serializeValue = async function(node, index) { return transformedValue } +// Notes: widget.options.serialize===false widgets (e.g. control_after_generate) still occupy a +// widgets_values slot and still fire serializeValue — excluded only from backend prompt by +// graphToPrompt(). See research/architecture/widget-serialization-historical-analysis.md. + +import { describe, it } from 'vitest' + +describe('BC.12 v1 contract — per-widget serialization transform', () => { + describe('S4.W3 — widget.serializeValue assignment', () => { + it.todo( + 'assigning widget.serializeValue = async fn(node, index) causes graphToPrompt() to await fn and use its return value in widgets_values' + ) + it.todo( + 'serializeValue receives the owning node as first argument and the widget\'s positional index in node.widgets as second argument' + ) + it.todo( + 'if serializeValue is not assigned, graphToPrompt() uses widget.value directly as the serialized value' + ) + it.todo( + 'serializeValue may return a value of a different type than widget.value (e.g. string expansion of a seed integer)' + ) + }) + + describe('serialize===false widgets (control_after_generate)', () => { + it.todo( + 'a widget with options.serialize===false still occupies a slot in the widgets_values positional array during serialization' + ) + it.todo( + 'serializeValue fires for a serialize===false widget and its return value appears in widgets_values even though graphToPrompt() excludes it from the backend prompt' + ) + it.todo( + 'the positional index passed to serializeValue for widgets after a serialize===false widget is offset by one relative to the backend prompt widgets_values array' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-12.v2.test.ts b/src/extension-api-v2/__tests__/bc-12.v2.test.ts new file mode 100644 index 0000000000..4b863184e9 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-12.v2.test.ts @@ -0,0 +1,47 @@ +// Category: BC.12 — Per-widget serialization transform +// DB cross-ref: S4.W3 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70 +// blast_radius: 5.58 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: WidgetHandle.on('serialize', fn) or WidgetHandle.setSerializeValue(fn) +// Notes: WidgetHandle identity is by name not position (PR #10392 widgets_values_named migration path). +// serialize===false widgets still fire the serialize event and still appear in the named map. + +import { describe, it } from 'vitest' + +describe('BC.12 v2 contract — per-widget serialization transform', () => { + describe('WidgetHandle.on(\'serialize\', fn) — event-based transform', () => { + it.todo( + 'WidgetHandle.on(\'serialize\', fn) fires fn during graphToPrompt(); fn may return a transformed value which replaces the default in the named map' + ) + it.todo( + 'fn receives a SerializeEvent with { node: NodeHandle, widget: WidgetHandle, value } and can set event.serializedValue to override' + ) + it.todo( + 'if no on(\'serialize\') listener is registered, graphToPrompt() uses WidgetHandle.value directly' + ) + it.todo( + 'on(\'serialize\') listener is removed when the extension scope is disposed; subsequent serializations use the raw value' + ) + }) + + describe('WidgetHandle.setSerializeValue(fn) — imperative transform assignment', () => { + it.todo( + 'WidgetHandle.setSerializeValue(async fn) registers fn as the sole serialize transform, superseding any prior assignment' + ) + it.todo( + 'fn passed to setSerializeValue receives (widgetHandle) and its return value is placed in widgets_values_named under the widget name' + ) + }) + + describe('serialize===false widgets (control_after_generate)', () => { + it.todo( + 'a widget with serialize===false still appears as a named entry in widgets_values_named during serialization' + ) + it.todo( + 'on(\'serialize\') fires for a serialize===false WidgetHandle; the returned value is stored in the named map but omitted from the backend prompt' + ) + it.todo( + 'WidgetHandle identity for serialize===false widgets is stable across slot reordering because it is name-based not position-based' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-13.migration.test.ts b/src/extension-api-v2/__tests__/bc-13.migration.test.ts new file mode 100644 index 0000000000..6dc31ecaae --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-13.migration.test.ts @@ -0,0 +1,44 @@ +// Category: BC.13 — Per-node serialization interception +// DB cross-ref: S2.N6, S2.N15 +// Exemplar: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438 +// Migration: v1 prototype.serialize patching / node.onSerialize → v2 NodeHandle.on('serialize') named-map + +import { describe, it } from 'vitest' + +describe('BC.13 migration — per-node serialization interception', () => { + describe('(a) positional v1 compat: prototype.serialize / onSerialize parity', () => { + it.todo( + 'custom field injected via v1 prototype.serialize patch and the same field injected via v2 on(\'serialize\') both appear in the serialized workflow JSON under identical keys' + ) + it.todo( + 'v1 onSerialize and v2 on(\'serialize\') both fire once per graphToPrompt() call with the same node\'s serialization data' + ) + it.todo( + 'v1 chain of two prototype.serialize patchers produces the same custom-field set as two v2 on(\'serialize\') listeners registered by separate extensions' + ) + }) + + describe('(b) named-map v2 round-trip parity', () => { + it.todo( + 'a workflow serialized under v2 with widgets_values_named and deserialized produces the same widget values as the equivalent v1 workflow with a positional widgets_values array' + ) + it.todo( + 'adding a new widget between two existing widgets does not shift the named-map entries for subsequent widgets (v2); it does shift positional indices in v1 — migration callers must stop relying on hardcoded indices' + ) + it.todo( + 'serialize===false widget (control_after_generate) occupies a named-map entry in v2 with no positional offset; v1 callers that computed offsets must remove that logic' + ) + }) + + describe('(c) null-in-numeric-widget: warning + default substitution', () => { + it.todo( + 'v1 NaN widget value silently becomes null in the workflow JSON; v2 substitutes the declared default and emits a console.warn — the logged message includes the node id and widget name' + ) + it.todo( + 'a workflow with a null widgets_values entry for a numeric widget loaded under v2 emits a console.warn and restores the declared default rather than loading null' + ) + it.todo( + 'the NaN guard does not trigger for non-numeric widgets whose value is legitimately null (e.g. unset optional inputs)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-13.v1.test.ts b/src/extension-api-v2/__tests__/bc-13.v1.test.ts new file mode 100644 index 0000000000..72a62a789f --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-13.v1.test.ts @@ -0,0 +1,53 @@ +// Category: BC.13 — Per-node serialization interception +// DB cross-ref: S2.N6, S2.N15 +// Exemplar: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438 +// blast_radius: 6.36 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v1 contract: node.prototype.serialize = function() { const r = origSerialize.call(this); r.myData = ...; return r } +// node.onSerialize = function(data) { data.myData = ... } +// Notes: widgets_values is positional. Three index-drift sources: control_after_generate slot occupancy, +// extension-injected widgets, V3 IO.MultiType topology-dependent widget count. NaN→null pipeline +// produces silent corruption. Test (a) positional v1 compat, (b) named-map v2 round-trip parity, +// (c) null-in-numeric-widget logs warning + substitutes default. + +import { describe, it } from 'vitest' + +describe('BC.13 v1 contract — per-node serialization interception', () => { + describe('S2.N6 — prototype.serialize patching', () => { + it.todo( + 'patching node.constructor.prototype.serialize and calling origSerialize.call(this) produces the base serialization object which can be extended with custom fields' + ) + it.todo( + 'custom fields added to the object returned by the patched serialize are present in the workflow JSON written to disk' + ) + it.todo( + 'multiple extensions each patching prototype.serialize via origSerialize chaining all contribute their custom fields to the final serialized object' + ) + it.todo( + 'positional widgets_values in the patched serialize output drifts when a serialize===false widget occupies a slot before the target widget' + ) + }) + + describe('S2.N15 — node.onSerialize callback', () => { + it.todo( + 'assigning node.onSerialize = fn causes fn to be called with the serialization data object after the base serialize completes' + ) + it.todo( + 'onSerialize may mutate data.myData in place; the mutation is reflected in the workflow JSON' + ) + it.todo( + 'NaN values written to widgets_values inside onSerialize are silently coerced to null by JSON.stringify, producing silent corruption' + ) + it.todo( + 'onSerialize fires once per serialization pass; calling graphToPrompt() twice calls onSerialize twice' + ) + }) + + describe('NaN→null silent corruption', () => { + it.todo( + 'a numeric widget whose serializeValue returns NaN causes a null entry in widgets_values after JSON round-trip' + ) + it.todo( + 'the null entry in widgets_values is loaded back as null on graph restore, not as 0 or the widget default' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-13.v2.test.ts b/src/extension-api-v2/__tests__/bc-13.v2.test.ts new file mode 100644 index 0000000000..34e6d75079 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-13.v2.test.ts @@ -0,0 +1,50 @@ +// Category: BC.13 — Per-node serialization interception +// DB cross-ref: S2.N6, S2.N15 +// Exemplar: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438 +// blast_radius: 6.36 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships +// v2 replacement: NodeHandle.on('serialize', (data) => { data.myData = ... }) — named map round-trip +// Notes: v2 uses widgets_values_named keyed by widget name, eliminating positional drift. +// NaN→null pipeline: v2 serializer logs a warning and substitutes the widget's declared default. + +import { describe, it } from 'vitest' + +describe('BC.13 v2 contract — per-node serialization interception', () => { + describe('NodeHandle.on(\'serialize\', fn) — node-level serialization hook (S2.N6, S2.N15)', () => { + it.todo( + 'NodeHandle.on(\'serialize\', fn) fires fn with the serialization data object during graphToPrompt(); fn may add custom fields' + ) + it.todo( + 'custom fields added to data inside on(\'serialize\') are present in the workflow JSON under the node\'s entry' + ) + it.todo( + 'multiple on(\'serialize\') listeners from different extensions all fire and their custom fields coexist without overwriting each other (assuming distinct keys)' + ) + it.todo( + 'on(\'serialize\') listener is removed when the extension scope is disposed; subsequent serializations omit the custom fields' + ) + }) + + describe('named-map round-trip (widgets_values_named)', () => { + it.todo( + 'v2 serialization stores widget values in a named map (widgets_values_named) keyed by widget name; the map survives a JSON round-trip with no null drift' + ) + it.todo( + 'a workflow serialized with three widgets including one serialize===false widget deserializes with correct values for all three regardless of insertion order' + ) + it.todo( + 'widgets added or removed between two serialization passes do not corrupt the named-map entries for unaffected widgets' + ) + }) + + describe('NaN→null guard (numeric widget safety)', () => { + it.todo( + 'when a numeric widget value resolves to NaN at serialization time, v2 logs a console warning and substitutes the widget\'s declared default value' + ) + it.todo( + 'the substituted default value round-trips through JSON correctly; the deserialized node shows the default, not null' + ) + it.todo( + 'NaN guard fires per-widget and does not abort the serialization of the remaining widgets on the same node' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-14.migration.test.ts b/src/extension-api-v2/__tests__/bc-14.migration.test.ts new file mode 100644 index 0000000000..ab78c9ea8f --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-14.migration.test.ts @@ -0,0 +1,40 @@ +// Category: BC.14 — Workflow → API serialization interception (graphToPrompt) +// DB cross-ref: S6.A1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781 +// blast_radius: 7.02 (HIGHEST in dataset) +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 app.graphToPrompt monkey-patch → v2 app.on('beforeGraphToPrompt', handler) + +import { describe, it } from 'vitest' + +describe('BC.14 migration — graphToPrompt interception', () => { + describe('payload equivalence', () => { + it.todo( + 'v1 monkey-patch and v2 beforeGraphToPrompt handler both receive equivalent { output, workflow } structures' + ) + it.todo( + 'custom metadata injected in v1 via return-value mutation is equally injectable via v2 payload mutation' + ) + it.todo( + 'v1 virtual-node removal logic produces the same serialized output as v2 automatic isVirtual resolution' + ) + }) + + describe('execution ordering', () => { + it.todo( + 'v2 handler fires at the same logical point in the queue pipeline as v1 wrapper (before HTTP dispatch)' + ) + it.todo( + 'v2 cancellation via payload.cancel() has equivalent effect to v1 throwing an error inside the wrapper' + ) + }) + + describe('coexistence during migration window', () => { + it.todo( + 'a v1 monkey-patch and a v2 beforeGraphToPrompt handler active simultaneously do not double-mutate the payload' + ) + it.todo( + 'removing the v1 monkey-patch while keeping the v2 handler produces identical final API payloads' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-14.v1.test.ts b/src/extension-api-v2/__tests__/bc-14.v1.test.ts new file mode 100644 index 0000000000..7045d44d46 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-14.v1.test.ts @@ -0,0 +1,32 @@ +// Category: BC.14 — Workflow → API serialization interception (graphToPrompt) +// DB cross-ref: S6.A1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781 +// blast_radius: 7.02 (HIGHEST in dataset) +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: monkey-patch app.graphToPrompt — const orig = app.graphToPrompt.bind(app); app.graphToPrompt = async function(...args) { const r = await orig(...args); /* mutate r */ return r } +// v2 replacement: app.on('beforeGraphToPrompt', (payload) => { /* mutate payload */ }) event with cancellable/mutable payload + +import { describe, it } from 'vitest' + +describe('BC.14 v1 contract — graphToPrompt monkey-patch', () => { + describe('S6.A1 — app.graphToPrompt interception', () => { + it.todo( + 'extension can replace app.graphToPrompt with a wrapper that calls the original and returns the result' + ) + it.todo( + 'wrapper receives the same positional arguments that the caller passed to app.graphToPrompt' + ) + it.todo( + 'mutations to the resolved prompt object (output, workflow) are reflected in the final API payload' + ) + it.todo( + 'virtual nodes resolved by the extension wrapper are absent from the serialized output sent to the backend' + ) + it.todo( + 'custom metadata injected into prompt.output is preserved through the full queuePrompt call' + ) + it.todo( + 'multiple extensions wrapping graphToPrompt in sequence each receive and pass through prior mutations' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-14.v2.test.ts b/src/extension-api-v2/__tests__/bc-14.v2.test.ts new file mode 100644 index 0000000000..9f6a3129be --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-14.v2.test.ts @@ -0,0 +1,43 @@ +// Category: BC.14 — Workflow → API serialization interception (graphToPrompt) +// DB cross-ref: S6.A1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781 +// blast_radius: 7.02 (HIGHEST in dataset) +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: app.on('beforeGraphToPrompt', (payload) => { /* mutate payload */ }) event with cancellable/mutable payload + +import { describe, it } from 'vitest' + +describe('BC.14 v2 contract — beforeGraphToPrompt event', () => { + describe('event registration and dispatch', () => { + it.todo( + 'app.on("beforeGraphToPrompt", handler) registers a handler that fires before every prompt serialization' + ) + it.todo( + 'handler receives a mutable payload object containing { output, workflow } matching the v1 return shape' + ) + it.todo( + 'mutations to payload.output inside the handler are present in the API body sent to the backend' + ) + it.todo( + 'handler can cancel serialization by calling payload.cancel(), preventing the queue call from proceeding' + ) + }) + + describe('virtual node resolution', () => { + it.todo( + 'virtual nodes declared via defineNodeExtension({ isVirtual: true }) are resolved before beforeGraphToPrompt fires' + ) + it.todo( + 'handler does not need to manually remove virtual nodes; they are absent from payload.output by default' + ) + }) + + describe('multiple handlers and ordering', () => { + it.todo( + 'multiple handlers registered with app.on("beforeGraphToPrompt") are called in registration order' + ) + it.todo( + 'each handler sees mutations made by prior handlers in the same event cycle' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-15.migration.test.ts b/src/extension-api-v2/__tests__/bc-15.migration.test.ts new file mode 100644 index 0000000000..ff45c3b12d --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-15.migration.test.ts @@ -0,0 +1,37 @@ +// Category: BC.15 — Workflow loading into the editor +// DB cross-ref: S6.A2 +// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456 +// blast_radius: 5.05 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 app.loadGraphData(json) → v2 app.loadWorkflow(json) with lifecycle hooks + +import { describe, it } from 'vitest' + +describe('BC.15 migration — workflow loading', () => { + describe('graph state equivalence', () => { + it.todo( + 'v1 app.loadGraphData(json) and v2 app.loadWorkflow(json) produce identical node/link graphs for the same input' + ) + it.todo( + 'node widget values are preserved identically between v1 and v2 load paths' + ) + it.todo( + 'custom node types registered by extensions are correctly hydrated by both v1 and v2 load paths' + ) + }) + + describe('interception migration', () => { + it.todo( + 'v1 monkey-patching app.loadGraphData to mutate json can be replaced by a v2 beforeLoadWorkflow handler with equivalent effect' + ) + it.todo( + 'v1 post-load logic run synchronously after app.loadGraphData can be moved to a v2 afterLoadWorkflow handler' + ) + }) + + describe('coexistence', () => { + it.todo( + 'calling v2 app.loadWorkflow does not break extensions that still listen on the legacy nodeCreated hook' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-15.v1.test.ts b/src/extension-api-v2/__tests__/bc-15.v1.test.ts new file mode 100644 index 0000000000..0428220f7d --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-15.v1.test.ts @@ -0,0 +1,28 @@ +// Category: BC.15 — Workflow loading into the editor +// DB cross-ref: S6.A2 +// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456 +// blast_radius: 5.05 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: app.loadGraphData(workflowJson) — direct call, no lifecycle events + +import { describe, it } from 'vitest' + +describe('BC.15 v1 contract — app.loadGraphData', () => { + describe('S6.A2 — direct workflow load', () => { + it.todo( + 'app.loadGraphData(json) replaces the current graph with the nodes and links from json' + ) + it.todo( + 'calling app.loadGraphData clears all existing nodes before deserializing the new workflow' + ) + it.todo( + 'node IDs in the loaded workflow are preserved as-is in the editor graph' + ) + it.todo( + 'app.loadGraphData accepts a plain JSON object (not a string) as its argument' + ) + it.todo( + 'extensions registered with nodeCreated receive each deserialized node after loadGraphData completes' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-15.v2.test.ts b/src/extension-api-v2/__tests__/bc-15.v2.test.ts new file mode 100644 index 0000000000..be2d395efc --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-15.v2.test.ts @@ -0,0 +1,40 @@ +// Category: BC.15 — Workflow loading into the editor +// DB cross-ref: S6.A2 +// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456 +// blast_radius: 5.05 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: app.loadWorkflow(json) — stable public API with beforeLoad/afterLoad hooks for intercepting extensions + +import { describe, it } from 'vitest' + +describe('BC.15 v2 contract — app.loadWorkflow', () => { + describe('core load API', () => { + it.todo( + 'app.loadWorkflow(json) loads workflow nodes and links into the editor, equivalent to v1 loadGraphData' + ) + it.todo( + 'app.loadWorkflow returns a Promise that resolves once all nodes are deserialized and rendered' + ) + it.todo( + 'app.loadWorkflow accepts both plain objects and JSON strings' + ) + }) + + describe('beforeLoad hook', () => { + it.todo( + 'app.on("beforeLoadWorkflow", handler) fires before the graph is cleared, allowing cancellation via event.cancel()' + ) + it.todo( + 'handler can mutate event.workflow to transform the incoming JSON before deserialization' + ) + }) + + describe('afterLoad hook', () => { + it.todo( + 'app.on("afterLoadWorkflow", handler) fires after all nodes are created, with the fully hydrated graph accessible' + ) + it.todo( + 'afterLoad handler receives the original workflow JSON alongside the live graph for cross-referencing' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-16.migration.test.ts b/src/extension-api-v2/__tests__/bc-16.migration.test.ts new file mode 100644 index 0000000000..88cf2ae4a2 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-16.migration.test.ts @@ -0,0 +1,37 @@ +// Category: BC.16 — Execution output consumption (per-node) +// DB cross-ref: S2.N2 +// Exemplar: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9 +// blast_radius: 4.67 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 node.onExecuted = fn → v2 NodeHandle.on('executed', fn) + +import { describe, it } from 'vitest' + +describe('BC.16 migration — per-node execution output', () => { + describe('data equivalence', () => { + it.todo( + 'v1 onExecuted data argument and v2 executed event data contain identical fields for the same backend response' + ) + it.todo( + 'data.text and data.images accessed in v2 handler match the same properties read in v1 onExecuted for the same execution' + ) + }) + + describe('timing equivalence', () => { + it.todo( + 'v2 NodeHandle.on("executed") fires at the same point in the WebSocket message processing pipeline as v1 onExecuted' + ) + it.todo( + 'DOM/widget updates performed in the v2 handler are applied within the same animation frame as equivalent v1 updates' + ) + }) + + describe('cleanup behaviour', () => { + it.todo( + 'v1 onExecuted persists after node removal (no automatic cleanup); v2 handler is removed automatically' + ) + it.todo( + 'explicitly calling the v2 unsubscribe function produces equivalent silence to never assigning v1 onExecuted' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-16.v1.test.ts b/src/extension-api-v2/__tests__/bc-16.v1.test.ts new file mode 100644 index 0000000000..cfa6978a5c --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-16.v1.test.ts @@ -0,0 +1,31 @@ +// Category: BC.16 — Execution output consumption (per-node) +// DB cross-ref: S2.N2 +// Exemplar: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9 +// blast_radius: 4.67 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: node.onExecuted = function(data) { /* data.text, data.images etc */ } + +import { describe, it } from 'vitest' + +describe('BC.16 v1 contract — node.onExecuted callback', () => { + describe('S2.N2 — per-node execution output', () => { + it.todo( + 'node.onExecuted is called by the runtime when the backend reports output for that node\'s ID' + ) + it.todo( + 'data.text is an array of strings when the node outputs text-type results' + ) + it.todo( + 'data.images is an array of image descriptor objects when the node outputs image-type results' + ) + it.todo( + 'data passed to onExecuted matches the raw output object from the backend executed event for that node' + ) + it.todo( + 'assigning node.onExecuted after graph load is sufficient; the handler receives subsequent execution outputs' + ) + it.todo( + 'onExecuted is not called for nodes whose IDs are absent from the execution output' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-16.v2.test.ts b/src/extension-api-v2/__tests__/bc-16.v2.test.ts new file mode 100644 index 0000000000..a518a53c88 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-16.v2.test.ts @@ -0,0 +1,40 @@ +// Category: BC.16 — Execution output consumption (per-node) +// DB cross-ref: S2.N2 +// Exemplar: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9 +// blast_radius: 4.67 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: NodeHandle.on('executed', (data) => { ... }) + +import { describe, it } from 'vitest' + +describe('BC.16 v2 contract — NodeHandle executed event', () => { + describe('event subscription', () => { + it.todo( + 'nodeHandle.on("executed", handler) registers a handler that fires when backend output arrives for that node' + ) + it.todo( + 'handler receives a typed data object with text, images, and any other output slots defined by the node\'s schema' + ) + it.todo( + 'nodeHandle.on("executed", ...) returns an unsubscribe function; calling it stops future invocations' + ) + }) + + describe('data shape and typing', () => { + it.todo( + 'data.text is typed as string[] for text-output nodes; accessing it does not require a cast' + ) + it.todo( + 'data.images is typed as ImageOutput[] for image-output nodes, including filename, subfolder, and type fields' + ) + }) + + describe('handler lifecycle', () => { + it.todo( + 'handlers registered via nodeHandle.on("executed") are automatically removed when the node is removed from the graph' + ) + it.todo( + 'multiple handlers on the same node each fire independently and in registration order' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-17.migration.test.ts b/src/extension-api-v2/__tests__/bc-17.migration.test.ts new file mode 100644 index 0000000000..046ec9c76f --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-17.migration.test.ts @@ -0,0 +1,43 @@ +// Category: BC.17 — Backend execution lifecycle and progress events +// DB cross-ref: S5.A1, S5.A2, S5.A3 +// Exemplar: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39 +// blast_radius: 5.00 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 app.api.addEventListener → v2 comfyApp.on with typed payloads + +import { describe, it } from 'vitest' + +describe('BC.17 migration — execution lifecycle events', () => { + describe('event payload equivalence (S5.A1 — executed / execution_error)', () => { + it.todo( + 'v1 "executed" CustomEvent.detail and v2 "executed" payload carry the same node ID and output fields' + ) + it.todo( + 'v1 "execution_error" detail and v2 "executionError" payload both identify the failing node and provide error text' + ) + }) + + describe('progress payload equivalence (S5.A2)', () => { + it.todo( + 'v1 progress detail { value, max } and v2 progress payload { step, totalSteps } encode the same completion fraction' + ) + }) + + describe('status and reconnect equivalence (S5.A3)', () => { + it.todo( + 'v1 "status" event and v2 "status" event fire at the same points in the WebSocket message lifecycle' + ) + it.todo( + 'v1 "reconnecting" event and v2 "reconnecting" event both fire before the first reconnect attempt' + ) + }) + + describe('handler removal equivalence', () => { + it.todo( + 'v1 app.api.removeEventListener(name, fn) and v2 unsubscribe() both stop the handler from firing on subsequent events' + ) + it.todo( + 'removing a v1 listener does not affect a concurrently registered v2 listener for the same logical event' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-17.v1.test.ts b/src/extension-api-v2/__tests__/bc-17.v1.test.ts new file mode 100644 index 0000000000..36368833f0 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-17.v1.test.ts @@ -0,0 +1,43 @@ +// Category: BC.17 — Backend execution lifecycle and progress events +// DB cross-ref: S5.A1, S5.A2, S5.A3 +// Exemplar: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39 +// blast_radius: 5.00 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: app.api.addEventListener('executed'|'progress'|'status'|'execution_error'|'reconnecting', fn) + +import { describe, it } from 'vitest' + +describe('BC.17 v1 contract — app.api.addEventListener', () => { + describe('S5.A1 — execution lifecycle events (executed, execution_error)', () => { + it.todo( + 'app.api.addEventListener("executed", fn) fires fn when a node execution completes with output data' + ) + it.todo( + 'app.api.addEventListener("execution_error", fn) fires fn with error detail when the backend reports a failure' + ) + it.todo( + 'the executed event detail includes { node, output } matching the backend WebSocket message structure' + ) + }) + + describe('S5.A2 — progress events', () => { + it.todo( + 'app.api.addEventListener("progress", fn) fires fn on each step tick during a running execution' + ) + it.todo( + 'the progress event detail includes { value, max } allowing accurate percentage calculation' + ) + }) + + describe('S5.A3 — status and reconnect events', () => { + it.todo( + 'app.api.addEventListener("status", fn) fires fn when the backend queue status changes' + ) + it.todo( + 'app.api.addEventListener("reconnecting", fn) fires fn when the WebSocket connection is lost and retrying' + ) + it.todo( + 'app.api.removeEventListener with the same event name and function reference removes the handler' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-17.v2.test.ts b/src/extension-api-v2/__tests__/bc-17.v2.test.ts new file mode 100644 index 0000000000..7527e48ce3 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-17.v2.test.ts @@ -0,0 +1,43 @@ +// Category: BC.17 — Backend execution lifecycle and progress events +// DB cross-ref: S5.A1, S5.A2, S5.A3 +// Exemplar: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39 +// blast_radius: 5.00 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: comfyApp.on('executed', fn), comfyApp.on('progress', fn) — typed event payloads + +import { describe, it } from 'vitest' + +describe('BC.17 v2 contract — comfyApp event subscriptions', () => { + describe('S5.A1 — execution lifecycle events', () => { + it.todo( + 'comfyApp.on("executed", fn) fires fn when a node reports completion, with a typed { nodeId, output } payload' + ) + it.todo( + 'comfyApp.on("executionError", fn) fires fn with a typed error payload including nodeId and exception detail' + ) + it.todo( + 'comfyApp.on("executionStart", fn) fires fn when the backend begins processing a new prompt' + ) + }) + + describe('S5.A2 — progress events', () => { + it.todo( + 'comfyApp.on("progress", fn) fires fn on each step tick with typed { step, totalSteps, nodeId } fields' + ) + it.todo( + 'progress percentage derived from v2 payload (step / totalSteps) equals percentage from v1 (value / max)' + ) + }) + + describe('S5.A3 — status and connectivity events', () => { + it.todo( + 'comfyApp.on("status", fn) fires fn when queue depth or running state changes, with a typed status payload' + ) + it.todo( + 'comfyApp.on("reconnecting", fn) fires fn when the WebSocket drops and a reconnect attempt begins' + ) + it.todo( + 'calling the unsubscribe handle returned by comfyApp.on() removes the handler without affecting other subscribers' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-18.migration.test.ts b/src/extension-api-v2/__tests__/bc-18.migration.test.ts new file mode 100644 index 0000000000..b38cf13455 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-18.migration.test.ts @@ -0,0 +1,40 @@ +// Category: BC.18 — Backend HTTP calls +// DB cross-ref: S6.A3 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61 +// blast_radius: 5.77 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 app.api.fetchApi → v2 comfyAPI.fetchApi (same signature, stable import) + +import { describe, it } from 'vitest' + +describe('BC.18 migration — backend HTTP calls', () => { + describe('request equivalence', () => { + it.todo( + 'v1 app.api.fetchApi(path, init) and v2 comfyAPI.fetchApi(path, init) send identical HTTP requests to the backend' + ) + it.todo( + 'authentication headers attached by v1 and v2 are equivalent; the backend accepts both without reconfiguration' + ) + it.todo( + 'FormData uploads via v1 and v2 produce the same multipart body on the wire' + ) + }) + + describe('response handling equivalence', () => { + it.todo( + 'v1 and v2 both return a native Response object; callers can use .json(), .text(), and .ok identically' + ) + it.todo( + '4xx/5xx responses resolve (not reject) in both v1 and v2, so existing error-check patterns remain valid' + ) + }) + + describe('import path migration', () => { + it.todo( + 'replacing "app.api.fetchApi" with an import of comfyAPI.fetchApi requires no call-site argument changes' + ) + it.todo( + 'comfyAPI.fetchApi is available at extension init time without waiting for app.setup() to complete' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-18.v1.test.ts b/src/extension-api-v2/__tests__/bc-18.v1.test.ts new file mode 100644 index 0000000000..df00f5eb44 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-18.v1.test.ts @@ -0,0 +1,31 @@ +// Category: BC.18 — Backend HTTP calls +// DB cross-ref: S6.A3 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61 +// blast_radius: 5.77 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: app.api.fetchApi('/endpoint', { method: 'POST', body: ... }) + +import { describe, it } from 'vitest' + +describe('BC.18 v1 contract — app.api.fetchApi', () => { + describe('S6.A3 — authenticated HTTP calls via fetchApi', () => { + it.todo( + 'app.api.fetchApi(path, init) returns a Promise from the ComfyUI backend origin' + ) + it.todo( + 'fetchApi prepends the configured base URL so callers use relative paths like "/upload/image"' + ) + it.todo( + 'fetchApi includes authentication headers (e.g. session cookie or Authorization) automatically' + ) + it.todo( + 'a POST call with a FormData body is forwarded without Content-Type override, allowing multipart to work' + ) + it.todo( + 'a non-2xx response from the backend is returned as a resolved Promise (not rejected); callers must check response.ok' + ) + it.todo( + 'concurrent fetchApi calls from different extensions do not share or corrupt each other\'s request state' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-18.v2.test.ts b/src/extension-api-v2/__tests__/bc-18.v2.test.ts new file mode 100644 index 0000000000..c281282fc3 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-18.v2.test.ts @@ -0,0 +1,40 @@ +// Category: BC.18 — Backend HTTP calls +// DB cross-ref: S6.A3 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61 +// blast_radius: 5.77 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: comfyAPI.fetchApi(path, opts) — same signature, same authentication, stable import path + +import { describe, it } from 'vitest' + +describe('BC.18 v2 contract — comfyAPI.fetchApi', () => { + describe('API surface stability', () => { + it.todo( + 'comfyAPI.fetchApi(path, init) is importable from the stable extension-api-v2 package without accessing app.api' + ) + it.todo( + 'comfyAPI.fetchApi signature is identical to v1 app.api.fetchApi: (path: string, init?: RequestInit) => Promise' + ) + it.todo( + 'comfyAPI.fetchApi uses the same base URL and authentication mechanism as v1 fetchApi' + ) + }) + + describe('request handling', () => { + it.todo( + 'POST with FormData body is forwarded correctly, preserving multipart boundary' + ) + it.todo( + 'JSON body with explicit Content-Type: application/json is sent without modification' + ) + it.todo( + 'non-2xx responses resolve (not reject) the returned Promise, consistent with v1 behaviour' + ) + }) + + describe('extension isolation', () => { + it.todo( + 'comfyAPI.fetchApi does not expose session credentials in a way that allows cross-extension credential theft' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-19.migration.test.ts b/src/extension-api-v2/__tests__/bc-19.migration.test.ts new file mode 100644 index 0000000000..8607841410 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-19.migration.test.ts @@ -0,0 +1,40 @@ +// Category: BC.19 — Workflow execution trigger +// DB cross-ref: S6.A4 +// Exemplar: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317 +// blast_radius: 6.09 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 app.queuePrompt monkey-patch → v2 comfyApp.on('beforeQueuePrompt') + comfyApp.queuePrompt(opts) + +import { describe, it } from 'vitest' + +describe('BC.19 migration — workflow execution trigger', () => { + describe('payload mutation equivalence', () => { + it.todo( + 'v1 wrapper mutation of the serialized prompt body and v2 event.payload mutation produce identical HTTP request bodies' + ) + it.todo( + 'auth tokens injected via v1 wrapper extra_data and v2 event.payload.extra_data reach the backend identically' + ) + }) + + describe('cancellation equivalence', () => { + it.todo( + 'v1 wrapper that does not call orig() and v2 handler that calls event.cancel() both result in zero HTTP calls to /prompt' + ) + }) + + describe('programmatic trigger equivalence', () => { + it.todo( + 'v1 app.queuePrompt(0, 1) and v2 comfyApp.queuePrompt({ batchCount: 1 }) both enqueue the same graph payload' + ) + it.todo( + 'v2 comfyApp.queuePrompt() fires beforeQueuePrompt handlers; v1 programmatic call also triggers any active v1 wrappers' + ) + }) + + describe('coexistence', () => { + it.todo( + 'a v1 monkey-patch and a v2 beforeQueuePrompt handler active simultaneously do not double-submit the prompt' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-19.v1.test.ts b/src/extension-api-v2/__tests__/bc-19.v1.test.ts new file mode 100644 index 0000000000..d27a07bef0 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-19.v1.test.ts @@ -0,0 +1,31 @@ +// Category: BC.19 — Workflow execution trigger +// DB cross-ref: S6.A4 +// Exemplar: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317 +// blast_radius: 6.09 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: monkey-patch app.queuePrompt — const orig = app.queuePrompt.bind(app); app.queuePrompt = async function(num, batchCount) { /* mutate */ return orig(num, batchCount) } + +import { describe, it } from 'vitest' + +describe('BC.19 v1 contract — app.queuePrompt monkey-patch', () => { + describe('S6.A4 — queuePrompt interception', () => { + it.todo( + 'extension can replace app.queuePrompt with a wrapper that calls the original and returns its result' + ) + it.todo( + 'wrapper receives (number, batchCount) arguments matching the internal call signature' + ) + it.todo( + 'extension can inject an auth token or extra field into the prompt payload before delegating to orig()' + ) + it.todo( + 'extension can prevent execution by not calling orig() inside the wrapper' + ) + it.todo( + 'multiple extensions wrapping queuePrompt in sequence each execute in wrapping order' + ) + it.todo( + 'programmatic call to app.queuePrompt(0, 1) from an extension correctly enqueues the current graph' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-19.v2.test.ts b/src/extension-api-v2/__tests__/bc-19.v2.test.ts new file mode 100644 index 0000000000..da063077cc --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-19.v2.test.ts @@ -0,0 +1,43 @@ +// Category: BC.19 — Workflow execution trigger +// DB cross-ref: S6.A4 +// Exemplar: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317 +// blast_radius: 6.09 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: comfyApp.on('beforeQueuePrompt', handler) with event.payload mutation; comfyApp.queuePrompt(opts) for programmatic trigger + +import { describe, it } from 'vitest' + +describe('BC.19 v2 contract — beforeQueuePrompt event and comfyApp.queuePrompt', () => { + describe('beforeQueuePrompt event', () => { + it.todo( + 'comfyApp.on("beforeQueuePrompt", handler) fires before every prompt is enqueued, including UI-triggered runs' + ) + it.todo( + 'handler receives a mutable event.payload containing the prompt body and extra_data fields' + ) + it.todo( + 'mutating event.payload.extra_data.extra_pnginfo in the handler persists into the queued request' + ) + it.todo( + 'calling event.cancel() inside the handler prevents the prompt from being submitted to the backend' + ) + }) + + describe('programmatic trigger', () => { + it.todo( + 'comfyApp.queuePrompt(opts) programmatically enqueues the current workflow, firing beforeQueuePrompt first' + ) + it.todo( + 'opts.batchCount defaults to 1 when omitted; the backend receives a single prompt' + ) + }) + + describe('multiple handlers', () => { + it.todo( + 'multiple beforeQueuePrompt handlers are called in registration order; each sees prior mutations' + ) + it.todo( + 'cancellation by any handler short-circuits remaining handlers and suppresses the HTTP call' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-20.migration.test.ts b/src/extension-api-v2/__tests__/bc-20.migration.test.ts new file mode 100644 index 0000000000..57bd59898c --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-20.migration.test.ts @@ -0,0 +1,46 @@ +// Category: BC.20 — Custom node-type registration (frontend-only / virtual) +// DB cross-ref: S1.H5, S1.H6, S8.P1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/rerouteNode.ts +// blast_radius: 5.49 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 LiteGraph.registerNodeType + isVirtualNode → v2 defineNodeExtension({ isVirtual: true, setup }) + +import { describe, it } from 'vitest' + +describe('BC.20 migration — custom and virtual node registration', () => { + describe('registration equivalence (S1.H5)', () => { + it.todo( + 'v1 LiteGraph.registerNodeType("MyType", MyClass) and v2 defineNodeExtension({ nodeType: "MyType" }) both make the type droppable from the node picker' + ) + it.todo( + 'v1 MyClass.prototype.isVirtualNode = true and v2 isVirtual: true both exclude the node from the graphToPrompt output' + ) + it.todo( + 'canvas rendering behaviour of a virtual node is identical between v1 and v2 registration paths' + ) + }) + + describe('augmentation equivalence (S1.H6)', () => { + it.todo( + 'v1 beforeRegisterNodeDef prototype mutation and v2 defineNodeExtension setup() widget addition produce equivalent UI on existing backend node types' + ) + it.todo( + 'widget values set via v2 setup(handle) are serialized identically to those set via v1 prototype augmentation' + ) + }) + + describe('serialization equivalence (S8.P1)', () => { + it.todo( + 'a graph with virtual nodes serialized via v1 graphToPrompt and the same graph using v2 produce bit-equivalent backend payloads' + ) + it.todo( + 'link re-routing through virtual nodes produces the same source→target pairs in both v1 and v2 serialized outputs' + ) + }) + + describe('cleanup on unregister', () => { + it.todo( + 'v1 registered types persist in LiteGraph after extension unregisters; v2 types registered via defineNodeExtension are removed' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-20.v1.test.ts b/src/extension-api-v2/__tests__/bc-20.v1.test.ts new file mode 100644 index 0000000000..14c1da7712 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-20.v1.test.ts @@ -0,0 +1,47 @@ +// Category: BC.20 — Custom node-type registration (frontend-only / virtual) +// DB cross-ref: S1.H5, S1.H6, S8.P1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/rerouteNode.ts +// blast_radius: 5.49 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: app.registerExtension({ registerCustomNodes(app) { LiteGraph.registerNodeType('MyType', MyClass); MyClass.prototype.isVirtualNode = true } }) +// app.registerExtension({ beforeRegisterNodeDef(nodeType, nodeData) { ... } }) + +import { describe, it } from 'vitest' + +describe('BC.20 v1 contract — LiteGraph.registerNodeType and isVirtualNode', () => { + describe('S1.H5 — registerCustomNodes hook', () => { + it.todo( + 'registerExtension({ registerCustomNodes(app) }) is called during setup before any graph is loaded' + ) + it.todo( + 'LiteGraph.registerNodeType("MyType", MyClass) inside registerCustomNodes makes the type instantiable in the graph' + ) + it.todo( + 'setting MyClass.prototype.isVirtualNode = true causes the serializer to omit the node from the backend API payload' + ) + it.todo( + 'virtual node is still visible and interactive in the LiteGraph canvas' + ) + }) + + describe('S1.H6 — beforeRegisterNodeDef hook', () => { + it.todo( + 'registerExtension({ beforeRegisterNodeDef(nodeType, nodeData) }) fires for every backend-defined node type before it is registered' + ) + it.todo( + 'extension can augment nodeType prototype inside beforeRegisterNodeDef and the change affects all future instances' + ) + it.todo( + 'mutations to nodeData inside beforeRegisterNodeDef alter the node\'s widget/input schema visible to the graph' + ) + }) + + describe('S8.P1 — virtual node payload suppression', () => { + it.todo( + 'graphToPrompt excludes nodes with isVirtualNode === true from the output object sent to the backend' + ) + it.todo( + 'links connected to a virtual node are re-routed in the serialized output to preserve logical connectivity' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-20.v2.test.ts b/src/extension-api-v2/__tests__/bc-20.v2.test.ts new file mode 100644 index 0000000000..0866ceead5 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-20.v2.test.ts @@ -0,0 +1,46 @@ +// Category: BC.20 — Custom node-type registration (frontend-only / virtual) +// DB cross-ref: S1.H5, S1.H6, S8.P1 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/rerouteNode.ts +// blast_radius: 5.49 (compat-floor) +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: defineNodeExtension({ nodeType: 'MyType', isVirtual: true, setup(handle) { ... } }) + +import { describe, it } from 'vitest' + +describe('BC.20 v2 contract — defineNodeExtension', () => { + describe('S1.H5 — virtual node registration', () => { + it.todo( + 'defineNodeExtension({ nodeType: "MyType", isVirtual: true, setup }) registers a pure-frontend node type' + ) + it.todo( + 'nodes registered with isVirtual: true do not appear in the serialized API payload from graphToPrompt' + ) + it.todo( + 'the virtual node is rendered on the canvas and accepts user interaction normally' + ) + it.todo( + 'setup(handle) receives a NodeHandle bound to every instance created at graph-load or user-drop time' + ) + }) + + describe('S1.H6 — backend node-def augmentation', () => { + it.todo( + 'defineNodeExtension({ nodeType: "ExistingBackendType", setup }) fires setup for every instance of a backend-defined type' + ) + it.todo( + 'extension can add widgets to the handle inside setup() and they appear on all matching nodes' + ) + it.todo( + 'schema-level augmentation (adding an input slot) declared via defineNodeExtension takes effect before the node is first rendered' + ) + }) + + describe('S8.P1 — serialization of virtual links', () => { + it.todo( + 'links through a virtual node are transparently resolved in the serialized output so backend sees direct source→target connections' + ) + it.todo( + 'removing the virtual node from the canvas also removes any dangling link stubs from the serialized payload' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-21.migration.test.ts b/src/extension-api-v2/__tests__/bc-21.migration.test.ts new file mode 100644 index 0000000000..d14c39e115 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-21.migration.test.ts @@ -0,0 +1,34 @@ +// Category: BC.21 — Custom widget-type registration +// DB cross-ref: S1.H2 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// blast_radius: 4.32 +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 getCustomWidgets factory → v2 defineWidgetExtension + +import { describe, it } from 'vitest' + +describe('BC.21 migration — Custom widget-type registration', () => { + describe('factory invocation parity (S1.H2)', () => { + it.todo( + 'v1 factory (node, inputData, app) and v2 create(handle, inputData) both receive equivalent inputData for the same node def' + ) + it.todo( + 'widget produced by v1 factory and v2 create have identical serialized value in node.widgets after creation' + ) + }) + + describe('registration timing', () => { + it.todo( + 'v1 getCustomWidgets fires during extension setup; v2 defineWidgetExtension registers before setup completes — both resolve before nodeCreated' + ) + }) + + describe('scope cleanup on dispose', () => { + it.todo( + 'v1 custom widget type persists after extension unregisters; v2 type is unregistered and nodes fall back to default rendering' + ) + it.todo( + 'v2 cleanup on dispose does not affect widget types registered by other extensions' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-21.v1.test.ts b/src/extension-api-v2/__tests__/bc-21.v1.test.ts new file mode 100644 index 0000000000..ab0229c74e --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-21.v1.test.ts @@ -0,0 +1,29 @@ +// Category: BC.21 — Custom widget-type registration +// DB cross-ref: S1.H2 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// blast_radius: 4.32 +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: app.registerExtension({ getCustomWidgets(app) { return { MYWIDGET: (node, inputData, app) => { ... } } } }) +// Notes: small family — 2 evidence rows + 1 minor variant (acceptance carve-out) + +import { describe, it } from 'vitest' + +describe('BC.21 v1 contract — Custom widget-type registration', () => { + describe('S1.H2 — getCustomWidgets hook', () => { + it.todo( + 'extension returning a widget factory from getCustomWidgets registers the type globally' + ) + it.todo( + 'registered widget factory is invoked with (node, inputData, app) when a node with that input type is created' + ) + it.todo( + 'widget returned by factory is attached to node.widgets array' + ) + it.todo( + 'two extensions registering distinct widget types do not collide' + ) + it.todo( + 'registering the same widget type key twice: second registration wins (last-write semantics)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-21.v2.test.ts b/src/extension-api-v2/__tests__/bc-21.v2.test.ts new file mode 100644 index 0000000000..1dae0ee917 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-21.v2.test.ts @@ -0,0 +1,28 @@ +// Category: BC.21 — Custom widget-type registration +// DB cross-ref: S1.H2 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// blast_radius: 4.32 +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: defineWidgetExtension({ widgetType: 'MYWIDGET', create(handle, inputData) { ... } }) + +import { describe, it } from 'vitest' + +describe('BC.21 v2 contract — Custom widget-type registration', () => { + describe('defineWidgetExtension() — declarative widget registration', () => { + it.todo( + 'defineWidgetExtension({ widgetType, create }) registers the type before any nodeCreated fires' + ) + it.todo( + 'create(handle, inputData) is called with a typed WidgetHandle and the input spec tuple' + ) + it.todo( + 'widget registered via defineWidgetExtension appears in NodeHandle.widgets after node creation' + ) + it.todo( + 'widget is removed from all nodes when the extension scope is disposed' + ) + it.todo( + 'defineWidgetExtension throws if widgetType is an empty string or conflicts with a built-in type' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-22.migration.test.ts b/src/extension-api-v2/__tests__/bc-22.migration.test.ts new file mode 100644 index 0000000000..1ffd4b89f4 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-22.migration.test.ts @@ -0,0 +1,44 @@ +// Category: BC.22 — Context menu contributions (node and canvas) +// DB cross-ref: S2.N5, S1.H3, S1.H4 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// blast_radius: 5.10 +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 getNodeMenuItems / prototype.getExtraMenuOptions / getCanvasMenuItems +// → v2 NodeHandle.addContextMenuItem / app.addCanvasMenuItem + +import { describe, it } from 'vitest' + +describe('BC.22 migration — Context menu contributions (node and canvas)', () => { + describe('node menu item parity (S1.H3 → NodeHandle.addContextMenuItem)', () => { + it.todo( + 'v1 getNodeMenuItems item and v2 addContextMenuItem item both appear in the node context menu with equal label text' + ) + it.todo( + 'action/callback invoked by clicking the item receives equivalent node context in both v1 and v2' + ) + }) + + describe('prototype patch migration (S2.N5 → NodeHandle.addContextMenuItem)', () => { + it.todo( + 'v1 prototype.getExtraMenuOptions items and v2 addContextMenuItem items both render in the same menu section' + ) + it.todo( + 'migrating from prototype patch removes the need to manually chain prior implementations' + ) + }) + + describe('canvas menu parity (S1.H4 → app.addCanvasMenuItem)', () => { + it.todo( + 'v1 getCanvasMenuItems item and v2 addCanvasMenuItem item both appear when right-clicking empty canvas' + ) + }) + + describe('scope cleanup on dispose', () => { + it.todo( + 'v1 menu items persist after extension unregisters; v2 items are removed on dispose' + ) + it.todo( + 'v2 item removal on dispose does not affect items contributed by other extensions' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-22.v1.test.ts b/src/extension-api-v2/__tests__/bc-22.v1.test.ts new file mode 100644 index 0000000000..033c7a3c1d --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-22.v1.test.ts @@ -0,0 +1,48 @@ +// Category: BC.22 — Context menu contributions (node and canvas) +// DB cross-ref: S2.N5, S1.H3, S1.H4 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// blast_radius: 5.10 +// compat-floor: blast_radius ≥ 2.0 +// v1 node: app.registerExtension({ getNodeMenuItems(value, options) { return [{ content: 'My Item', callback: fn }] } }) +// or node.prototype.getExtraMenuOptions = function(...) { return [...] } +// v1 canvas: app.registerExtension({ getCanvasMenuItems() { return [{ content: 'Canvas Option', callback: fn }] } }) + +import { describe, it } from 'vitest' + +describe('BC.22 v1 contract — Context menu contributions (node and canvas)', () => { + describe('S1.H3 — getNodeMenuItems hook', () => { + it.todo( + 'extension returning items from getNodeMenuItems appends them to the node right-click menu' + ) + it.todo( + 'getNodeMenuItems receives (value, options) where options.node is the right-clicked LGraph node' + ) + it.todo( + 'returning null or undefined from getNodeMenuItems does not break the menu' + ) + it.todo( + 'multiple extensions contributing node menu items all appear in the same context menu' + ) + }) + + describe('S2.N5 — prototype patch getExtraMenuOptions', () => { + it.todo( + 'assigning node.prototype.getExtraMenuOptions appends extra items to the node context menu' + ) + it.todo( + 'prototype-patched getExtraMenuOptions receives (app, options) and its items are merged after built-ins' + ) + it.todo( + 'multiple prototype patches chain correctly without overwriting each other' + ) + }) + + describe('S1.H4 — getCanvasMenuItems hook', () => { + it.todo( + 'extension returning items from getCanvasMenuItems appends them to the canvas right-click menu' + ) + it.todo( + 'getCanvasMenuItems items appear only when no node is right-clicked' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-22.v2.test.ts b/src/extension-api-v2/__tests__/bc-22.v2.test.ts new file mode 100644 index 0000000000..b5a0d3e28c --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-22.v2.test.ts @@ -0,0 +1,38 @@ +// Category: BC.22 — Context menu contributions (node and canvas) +// DB cross-ref: S2.N5, S1.H3, S1.H4 +// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/ +// blast_radius: 5.10 +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: NodeHandle.addContextMenuItem(opts), app.addCanvasMenuItem(opts) +// registered items removed on extension dispose + +import { describe, it } from 'vitest' + +describe('BC.22 v2 contract — Context menu contributions (node and canvas)', () => { + describe('NodeHandle.addContextMenuItem() — node-scoped menu items', () => { + it.todo( + 'NodeHandle.addContextMenuItem({ label, action }) appends the item to that node\'s right-click menu' + ) + it.todo( + 'action callback receives a MenuItemContext with the target NodeHandle' + ) + it.todo( + 'addContextMenuItem returns a disposable; calling it removes only that item' + ) + it.todo( + 'item added via addContextMenuItem is removed automatically when the extension scope is disposed' + ) + }) + + describe('app.addCanvasMenuItem() — canvas-scoped menu items', () => { + it.todo( + 'app.addCanvasMenuItem({ label, action }) appends the item to the canvas right-click menu' + ) + it.todo( + 'canvas menu item is visible only when right-clicking empty canvas (no node hit)' + ) + it.todo( + 'canvas menu item is removed when the extension scope is disposed' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-23.migration.test.ts b/src/extension-api-v2/__tests__/bc-23.migration.test.ts new file mode 100644 index 0000000000..92741fb297 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-23.migration.test.ts @@ -0,0 +1,38 @@ +// Category: BC.23 — Node property bag mutations +// DB cross-ref: S2.N18 +// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78 +// blast_radius: 5.82 +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 onPropertyChanged prototype patch / node.properties direct write +// → v2 NodeHandle.on('propertyChanged') / NodeHandle.setProperty + +import { describe, it } from 'vitest' + +describe('BC.23 migration — Node property bag mutations', () => { + describe('observer parity (S2.N18)', () => { + it.todo( + 'v1 onPropertyChanged and v2 propertyChanged listener both receive identical (name, value, prevValue) for the same mutation' + ) + it.todo( + 'v2 listener fires for writes made via NodeHandle.setProperty; v1 hook fires for the same via native property set path' + ) + }) + + describe('persistence parity', () => { + it.todo( + 'property written via v1 node.properties.myKey and v2 NodeHandle.setProperty both round-trip through JSON serialization identically' + ) + it.todo( + 'property survives node.clone() in both v1 and v2 paths' + ) + }) + + describe('scope cleanup on dispose', () => { + it.todo( + 'v1 prototype.onPropertyChanged persists after extension unregisters; v2 listener is removed on dispose' + ) + it.todo( + 'v2 listener removal on dispose does not silence listeners registered by other extensions on the same node' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-23.v1.test.ts b/src/extension-api-v2/__tests__/bc-23.v1.test.ts new file mode 100644 index 0000000000..0e920c3109 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-23.v1.test.ts @@ -0,0 +1,38 @@ +// Category: BC.23 — Node property bag mutations +// DB cross-ref: S2.N18 +// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78 +// blast_radius: 5.82 +// compat-floor: blast_radius ≥ 2.0 +// v1: node.prototype.onPropertyChanged = function(name, value, prevValue) { ... } +// or node.properties.myKey = value + +import { describe, it } from 'vitest' + +describe('BC.23 v1 contract — Node property bag mutations', () => { + describe('S2.N18 — onPropertyChanged lifecycle hook', () => { + it.todo( + 'assigning node.prototype.onPropertyChanged wires a callback invoked when any property value changes' + ) + it.todo( + 'onPropertyChanged receives (name, value, prevValue) with correct types for each argument' + ) + it.todo( + 'onPropertyChanged is NOT called for properties set before the node is created' + ) + it.todo( + 'multiple prototype patches to onPropertyChanged: later patch overwrites earlier unless manually chained' + ) + }) + + describe('S2.N18 — direct node.properties mutation', () => { + it.todo( + 'setting node.properties.myKey = value persists the value through graph serialization and deserialization' + ) + it.todo( + 'direct property mutation does not automatically trigger onPropertyChanged' + ) + it.todo( + 'properties bag survives node clone (node.clone() copies node.properties by value)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-23.v2.test.ts b/src/extension-api-v2/__tests__/bc-23.v2.test.ts new file mode 100644 index 0000000000..818eebdf9d --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-23.v2.test.ts @@ -0,0 +1,38 @@ +// Category: BC.23 — Node property bag mutations +// DB cross-ref: S2.N18 +// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78 +// blast_radius: 5.82 +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: NodeHandle.on('propertyChanged', (name, value, prevValue) => { ... }) +// NodeHandle.setProperty(name, value) + +import { describe, it } from 'vitest' + +describe('BC.23 v2 contract — Node property bag mutations', () => { + describe('NodeHandle.on(\'propertyChanged\') — reactive property observation', () => { + it.todo( + 'NodeHandle.on(\'propertyChanged\', cb) fires cb with (name, value, prevValue) on every property write' + ) + it.todo( + 'propertyChanged event fires for mutations made via both NodeHandle.setProperty and direct node.properties writes' + ) + it.todo( + 'multiple listeners on the same node all receive the event independently' + ) + it.todo( + 'listener registered via NodeHandle.on is removed when the extension scope is disposed' + ) + }) + + describe('NodeHandle.setProperty() — managed property mutation', () => { + it.todo( + 'NodeHandle.setProperty(name, value) updates node.properties[name] and triggers propertyChanged listeners' + ) + it.todo( + 'value set via setProperty survives graph serialization and deserialization' + ) + it.todo( + 'setProperty with the same value as current does not fire propertyChanged (no-op guard)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-24.migration.test.ts b/src/extension-api-v2/__tests__/bc-24.migration.test.ts new file mode 100644 index 0000000000..cacc8208ec --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-24.migration.test.ts @@ -0,0 +1,37 @@ +// Category: BC.24 — Node-def schema inspection +// DB cross-ref: S13.SC1 +// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1 +// blast_radius: 5.00 +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 raw nodeData property access → v2 NodeHandle.def / NodeHandle.inputDefs / NodeHandle.outputDefs + +import { describe, it } from 'vitest' + +describe('BC.24 migration — Node-def schema inspection', () => { + describe('input schema parity (S13.SC1)', () => { + it.todo( + 'v1 nodeData.input.required and v2 NodeHandle.def.input.required contain identical keys for the same node type' + ) + it.todo( + 'v1 InputSpec tuple first element and v2 InputDef.type are equal strings for every slot' + ) + it.todo( + 'v1 nodeData.input.optional and v2 NodeHandle.def.input.optional both reflect server-provided optional inputs' + ) + }) + + describe('output schema parity', () => { + it.todo( + 'v1 nodeData.output array and v2 NodeHandle.def.output have the same length and type strings in slot order' + ) + it.todo( + 'v1 nodeData.output_node and v2 NodeHandle.def.output_node are the same boolean value' + ) + }) + + describe('category parity', () => { + it.todo( + 'v1 nodeData.category and v2 NodeHandle.def.category are identical strings for the same node type' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-24.v1.test.ts b/src/extension-api-v2/__tests__/bc-24.v1.test.ts new file mode 100644 index 0000000000..93a12c1d90 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-24.v1.test.ts @@ -0,0 +1,41 @@ +// Category: BC.24 — Node-def schema inspection +// DB cross-ref: S13.SC1 +// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1 +// blast_radius: 5.00 +// compat-floor: blast_radius ≥ 2.0 +// v1: direct inspection of nodeData.input.required, nodeData.input.optional, nodeData.output, +// nodeData.output_node, nodeData.category, InputSpec sentinel tuples + +import { describe, it } from 'vitest' + +describe('BC.24 v1 contract — Node-def schema inspection', () => { + describe('S13.SC1 — input slot inspection', () => { + it.todo( + 'nodeData.input.required is an object mapping slot names to InputSpec tuples [type, opts?]' + ) + it.todo( + 'nodeData.input.optional is an object mapping slot names to InputSpec tuples and may be undefined' + ) + it.todo( + 'nodeData.input.hidden is an object or undefined; hidden inputs do not appear in the node UI' + ) + it.todo( + 'InputSpec tuple first element is a string type name or array of enum values' + ) + }) + + describe('S13.SC1 — output slot inspection', () => { + it.todo( + 'nodeData.output is an array of output type name strings in slot order' + ) + it.todo( + 'nodeData.output_node is a boolean indicating whether this node routes data to the server output' + ) + }) + + describe('S13.SC1 — category inspection', () => { + it.todo( + 'nodeData.category is a slash-delimited string used to place the node in the Add Node menu hierarchy' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-24.v2.test.ts b/src/extension-api-v2/__tests__/bc-24.v2.test.ts new file mode 100644 index 0000000000..ae5e506864 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-24.v2.test.ts @@ -0,0 +1,44 @@ +// Category: BC.24 — Node-def schema inspection +// DB cross-ref: S13.SC1 +// Exemplar: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1 +// blast_radius: 5.00 +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: NodeHandle.def — typed ComfyNodeDef shape with same fields but typed accessors +// NodeHandle.inputDefs, NodeHandle.outputDefs + +import { describe, it } from 'vitest' + +describe('BC.24 v2 contract — Node-def schema inspection', () => { + describe('NodeHandle.def — typed ComfyNodeDef accessor', () => { + it.todo( + 'NodeHandle.def.input.required is a typed Record mirroring the v1 shape' + ) + it.todo( + 'NodeHandle.def.input.optional is a typed Record or undefined' + ) + it.todo( + 'NodeHandle.def.output is a typed readonly array of OutputDef in slot order' + ) + it.todo( + 'NodeHandle.def.output_node is a boolean identical to the server-provided value' + ) + it.todo( + 'NodeHandle.def.category is the slash-delimited category string' + ) + }) + + describe('NodeHandle.inputDefs — convenience accessor', () => { + it.todo( + 'NodeHandle.inputDefs returns a flat array merging required and optional inputs with a slot-order index' + ) + it.todo( + 'each InputDef entry exposes .name, .type, .required, and .options fields' + ) + }) + + describe('NodeHandle.outputDefs — convenience accessor', () => { + it.todo( + 'NodeHandle.outputDefs returns an array of OutputDef with .name, .type, and .index fields' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-25.migration.test.ts b/src/extension-api-v2/__tests__/bc-25.migration.test.ts new file mode 100644 index 0000000000..4fe7194fd2 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-25.migration.test.ts @@ -0,0 +1,44 @@ +// Category: BC.25 — Shell UI registration (commands, sidebars, toasts) +// DB cross-ref: S12.UI1 +// Exemplar: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269 +// blast_radius: 4.02 +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 extensionManager / commandManager / toastManager imports +// → v2 comfyApp.registerSidebarTab / registerCommand / showToast (stable import path) + +import { describe, it } from 'vitest' + +describe('BC.25 migration — Shell UI registration (commands, sidebars, toasts)', () => { + describe('sidebar tab parity (S12.UI1)', () => { + it.todo( + 'v1 extensionManager.registerSidebarTab and v2 comfyApp.registerSidebarTab both result in a visible tab with equivalent id and title' + ) + it.todo( + 'v2 tab render context provides the same root element accessible in v1 raw render callback' + ) + }) + + describe('command parity', () => { + it.todo( + 'command registered via v1 commandManager.registerCommand and v2 comfyApp.registerCommand are both invocable by the same id' + ) + it.todo( + 'execute/function callback receives equivalent context objects in v1 and v2' + ) + }) + + describe('toast parity', () => { + it.todo( + 'v1 toastManager.add and v2 comfyApp.showToast both display a notification with the same severity and summary text' + ) + it.todo( + 'auto-dismiss timing is equivalent between v1 life and v2 life options' + ) + }) + + describe('scope cleanup on dispose', () => { + it.todo( + 'v1 sidebar tabs and commands persist after extension unregisters; v2 contributions are removed on dispose' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-25.v1.test.ts b/src/extension-api-v2/__tests__/bc-25.v1.test.ts new file mode 100644 index 0000000000..ca3c76987f --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-25.v1.test.ts @@ -0,0 +1,52 @@ +// Category: BC.25 — Shell UI registration (commands, sidebars, toasts) +// DB cross-ref: S12.UI1 +// Exemplar: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269 +// blast_radius: 4.02 +// compat-floor: blast_radius ≥ 2.0 +// v1: app.registerExtension({ settings: [...] }) +// extensionManager.registerSidebarTab(opts) +// commandManager.registerCommand(opts) +// toastManager.add(opts) + +import { describe, it } from 'vitest' + +describe('BC.25 v1 contract — Shell UI registration (commands, sidebars, toasts)', () => { + describe('S12.UI1 — settings registration', () => { + it.todo( + 'extension passing a settings array to registerExtension adds each setting to the ComfyUI settings panel' + ) + it.todo( + 'registered setting value is readable via app.ui.settings.getSettingValue(id) after registration' + ) + it.todo( + 'setting onChange callback fires when the user changes the value in the settings panel' + ) + }) + + describe('S12.UI1 — sidebar tab registration', () => { + it.todo( + 'extensionManager.registerSidebarTab({ id, icon, title, render }) adds a tab to the sidebar' + ) + it.todo( + 'render function is called with the tab container element when the tab is first activated' + ) + }) + + describe('S12.UI1 — command registration', () => { + it.todo( + 'commandManager.registerCommand({ id, label, function }) makes the command invocable by id' + ) + it.todo( + 'registered command appears in the command palette UI' + ) + }) + + describe('S12.UI1 — toast notifications', () => { + it.todo( + 'toastManager.add({ severity, summary, detail }) displays a toast notification in the UI' + ) + it.todo( + 'toast with a specified life value auto-dismisses after the given number of milliseconds' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-25.v2.test.ts b/src/extension-api-v2/__tests__/bc-25.v2.test.ts new file mode 100644 index 0000000000..5472ee0c82 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-25.v2.test.ts @@ -0,0 +1,48 @@ +// Category: BC.25 — Shell UI registration (commands, sidebars, toasts) +// DB cross-ref: S12.UI1 +// Exemplar: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269 +// blast_radius: 4.02 +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: same APIs stabilized — comfyApp.registerSidebarTab(opts), +// comfyApp.registerCommand(opts), comfyApp.showToast(opts) +// consistent import path from @comfyorg/extension-api + +import { describe, it } from 'vitest' + +describe('BC.25 v2 contract — Shell UI registration (commands, sidebars, toasts)', () => { + describe('comfyApp.registerSidebarTab() — stabilized sidebar API', () => { + it.todo( + 'comfyApp.registerSidebarTab({ id, icon, title, render }) adds a tab accessible in the sidebar' + ) + it.todo( + 'sidebar tab registered via comfyApp is removed when the extension scope is disposed' + ) + it.todo( + 'render receives a typed SidebarTabContext instead of a raw DOM element' + ) + }) + + describe('comfyApp.registerCommand() — stabilized command API', () => { + it.todo( + 'comfyApp.registerCommand({ id, label, execute }) makes the command invocable by id' + ) + it.todo( + 'command appears in the command palette with the provided label' + ) + it.todo( + 'command is unregistered when the extension scope is disposed' + ) + }) + + describe('comfyApp.showToast() — stabilized toast API', () => { + it.todo( + 'comfyApp.showToast({ severity, summary, detail }) displays a toast notification' + ) + it.todo( + 'showToast with life option auto-dismisses after the specified duration' + ) + it.todo( + 'showToast returns a handle with a dismiss() method for programmatic removal' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-26.migration.test.ts b/src/extension-api-v2/__tests__/bc-26.migration.test.ts new file mode 100644 index 0000000000..5b3f49a71b --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-26.migration.test.ts @@ -0,0 +1,41 @@ +// Category: BC.26 — Globals as ABI (window.LiteGraph, window.comfyAPI) +// DB cross-ref: S7.G1 +// Exemplar: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1 +// blast_radius: 4.55 +// compat-floor: blast_radius ≥ 2.0 +// Migration: v1 window.LiteGraph / window.comfyAPI / window.app access +// → v2 explicit named imports from @comfyorg/extension-api + +import { describe, it } from 'vitest' + +describe('BC.26 migration — Globals as ABI (window.LiteGraph, window.comfyAPI)', () => { + describe('LiteGraph reference parity (S7.G1)', () => { + it.todo( + 'window.LiteGraph.LGraphNode and the named import LGraphNode from @comfyorg/extension-api are the same constructor reference' + ) + it.todo( + 'a node registered via window.LiteGraph.registerNodeType is identical to one registered via the v2 import path' + ) + it.todo( + 'LiteGraph enum values accessed via window and via import are strictly equal (===)' + ) + }) + + describe('comfyAPI / comfyApp reference parity', () => { + it.todo( + 'window.app and the imported comfyApp share the same graph state — mutations via one are visible on the other' + ) + it.todo( + 'window.comfyAPI.modules.extensionService and imported extensionManager refer to the same instance' + ) + }) + + describe('deprecation signal migration', () => { + it.todo( + 'replacing window.LiteGraph access with named imports removes all deprecation console warnings' + ) + it.todo( + 'replacing window.comfyAPI access with named imports removes all deprecation console warnings' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-26.v1.test.ts b/src/extension-api-v2/__tests__/bc-26.v1.test.ts new file mode 100644 index 0000000000..583775ce75 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-26.v1.test.ts @@ -0,0 +1,43 @@ +// Category: BC.26 — Globals as ABI (window.LiteGraph, window.comfyAPI) +// DB cross-ref: S7.G1 +// Exemplar: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1 +// blast_radius: 4.55 +// compat-floor: blast_radius ≥ 2.0 +// v1: window.LiteGraph.registerNodeType(...), window.comfyAPI.modules.extensionService, window.app + +import { describe, it } from 'vitest' + +describe('BC.26 v1 contract — Globals as ABI (window.LiteGraph, window.comfyAPI)', () => { + describe('S7.G1 — window.LiteGraph global usage', () => { + it.todo( + 'window.LiteGraph is defined and exposes registerNodeType, LGraph, LGraphNode, and LLink constructors' + ) + it.todo( + 'window.LiteGraph.registerNodeType(type, ctor) registers a custom node type visible in the Add Node menu' + ) + it.todo( + 'LiteGraph enum constants (e.g. LiteGraph.INPUT, LiteGraph.OUTPUT) are accessible via window.LiteGraph' + ) + }) + + describe('S7.G1 — window.comfyAPI global registry', () => { + it.todo( + 'window.comfyAPI is defined after the app boots and exposes a modules sub-object' + ) + it.todo( + 'window.comfyAPI.modules.extensionService references the active extensionManager instance' + ) + it.todo( + 'services accessed via window.comfyAPI.modules are the same objects as those available via ES module import' + ) + }) + + describe('S7.G1 — window.app global', () => { + it.todo( + 'window.app is defined and is the same object as the app instance passed to extension hooks' + ) + it.todo( + 'mutations made to the graph via window.app are reflected in the live canvas immediately' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-26.v2.test.ts b/src/extension-api-v2/__tests__/bc-26.v2.test.ts new file mode 100644 index 0000000000..501461f063 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-26.v2.test.ts @@ -0,0 +1,44 @@ +// Category: BC.26 — Globals as ABI (window.LiteGraph, window.comfyAPI) +// DB cross-ref: S7.G1 +// Exemplar: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1 +// blast_radius: 4.55 +// compat-floor: blast_radius ≥ 2.0 +// v2 replacement: explicit imports from @comfyorg/extension-api +// globals still exported for compat shim but deprecated + +import { describe, it } from 'vitest' + +describe('BC.26 v2 contract — Globals as ABI (window.LiteGraph, window.comfyAPI)', () => { + describe('explicit LiteGraph imports from @comfyorg/extension-api', () => { + it.todo( + 'LGraph, LGraphNode, LLink are importable by name from @comfyorg/extension-api' + ) + it.todo( + 'LiteGraph enum constants (INPUT, OUTPUT, etc.) are importable as named exports' + ) + it.todo( + 'imported constructors are the same references as window.LiteGraph equivalents during the compat shim window' + ) + }) + + describe('explicit comfyApp / service imports', () => { + it.todo( + 'comfyApp is importable from @comfyorg/extension-api and is the same instance as window.app' + ) + it.todo( + 'extensionManager is importable from @comfyorg/extension-api and is the same instance as window.comfyAPI.modules.extensionService' + ) + }) + + describe('compat shim deprecation', () => { + it.todo( + 'accessing window.LiteGraph in v2 mode emits a deprecation warning to the console' + ) + it.todo( + 'accessing window.comfyAPI in v2 mode emits a deprecation warning to the console' + ) + it.todo( + 'compat shim globals are still functional (not removed) so v1 extensions continue working during migration window' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-27.migration.test.ts b/src/extension-api-v2/__tests__/bc-27.migration.test.ts new file mode 100644 index 0000000000..e76cfba318 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-27.migration.test.ts @@ -0,0 +1,46 @@ +// Category: BC.27 — LiteGraph entity direct manipulation (reroute, group, link, slot) +// DB cross-ref: S9.R1, S9.G1, S9.L1, S9.S1 +// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1 +// blast_radius: 5.62 +// compat-floor: blast_radius ≥ 2.0 +// migration: direct raw object mutations → read-only v2 accessors (mutations deferred to D9 Phase C) + +import { describe, it } from 'vitest' + +describe('BC.27 migration — LiteGraph entity direct manipulation', () => { + describe('reroute migration', () => { + it.todo( + 'v1 graph.reroutes raw access is replaced by comfyApp.graph.reroutes iterable' + ) + it.todo( + 'v1 direct position mutation (graph.reroutes[id].pos = [...]) has no v2 equivalent until D9 Phase C' + ) + }) + + describe('group migration', () => { + it.todo( + 'v1 graph.groups[i].title mutation is replaced by a future GroupHandle.setTitle() (D9 Phase C)' + ) + it.todo( + 'v1 graph.groups iteration is replaced by comfyApp.graph.groups read-only iterable' + ) + }) + + describe('link migration', () => { + it.todo( + 'v1 link.color direct assignment is replaced by a future LinkHandle.setColor() (D9 Phase C)' + ) + it.todo( + 'v2 compat shim logs a deprecation warning when graph.links is accessed directly' + ) + }) + + describe('slot migration', () => { + it.todo( + 'v1 node.inputs[i].shape mutation has no v2 equivalent until D9 Phase C' + ) + it.todo( + 'v2 compat shim throws a TypeError when slot mutation is attempted via legacy path' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-27.v1.test.ts b/src/extension-api-v2/__tests__/bc-27.v1.test.ts new file mode 100644 index 0000000000..b767c82cc0 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-27.v1.test.ts @@ -0,0 +1,52 @@ +// Category: BC.27 — LiteGraph entity direct manipulation (reroute, group, link, slot) +// DB cross-ref: S9.R1, S9.G1, S9.L1, S9.S1 +// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1 +// blast_radius: 5.62 +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: direct graph.reroutes, graph.groups, link.color, slot.shape mutations — no API, raw object access + +import { describe, it } from 'vitest' + +describe('BC.27 v1 contract — LiteGraph entity direct manipulation', () => { + describe('S9.R1 — reroute direct access', () => { + it.todo( + 'extension can read graph.reroutes and iterate all reroute nodes in the graph' + ) + it.todo( + 'extension can mutate reroute position directly via graph.reroutes[id].pos' + ) + it.todo( + 'reroute additions via graph.reroutes[id] = { ... } are reflected in the rendered canvas' + ) + }) + + describe('S9.G1 — group direct access', () => { + it.todo( + 'extension can read graph.groups and iterate all groups' + ) + it.todo( + 'extension can mutate group title via graph.groups[i].title = string' + ) + it.todo( + 'extension can mutate group bounding box via graph.groups[i].bounding' + ) + }) + + describe('S9.L1 — link direct access', () => { + it.todo( + 'extension can read link.color and link.type directly from graph.links[id]' + ) + it.todo( + 'setting link.color mutates the rendered link color without requiring graph refresh' + ) + }) + + describe('S9.S1 — slot direct access', () => { + it.todo( + 'extension can read node.inputs[i].shape and node.outputs[i].shape directly' + ) + it.todo( + 'extension can mutate slot.shape to change rendered connector shape' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-27.v2.test.ts b/src/extension-api-v2/__tests__/bc-27.v2.test.ts new file mode 100644 index 0000000000..786a4efe53 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-27.v2.test.ts @@ -0,0 +1,50 @@ +// Category: BC.27 — LiteGraph entity direct manipulation (reroute, group, link, slot) +// DB cross-ref: S9.R1, S9.G1, S9.L1, S9.S1 +// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1 +// blast_radius: 5.62 +// compat-floor: blast_radius ≥ 2.0 +// v2 contract: partial — reroute/group/link read APIs planned; mutations deferred to D9 Phase C. +// For now: read-only accessors + +import { describe, it } from 'vitest' + +describe('BC.27 v2 contract — LiteGraph entity direct manipulation', () => { + describe('S9.R1 — reroute read-only accessors', () => { + it.todo( + 'comfyApp.graph.reroutes returns an iterable of read-only RerouteHandle objects' + ) + it.todo( + 'RerouteHandle exposes id, pos, and linked link IDs as read-only properties' + ) + it.todo( + 'attempting to mutate RerouteHandle.pos in v2 throws or is silently ignored (write-protect)' + ) + }) + + describe('S9.G1 — group read-only accessors', () => { + it.todo( + 'comfyApp.graph.groups returns an iterable of read-only GroupHandle objects' + ) + it.todo( + 'GroupHandle exposes title and bounding as read-only (mutations deferred to D9 Phase C)' + ) + }) + + describe('S9.L1 — link read-only accessors', () => { + it.todo( + 'comfyApp.graph.links returns a Map with read-only color and type' + ) + it.todo( + 'link mutation API is not available in v2 Phase A (deferred to D9 Phase C)' + ) + }) + + describe('S9.S1 — slot read-only accessors', () => { + it.todo( + 'NodeHandle.inputs and NodeHandle.outputs expose read-only SlotHandle with shape' + ) + it.todo( + 'slot shape mutation is not available in v2 Phase A (deferred to D9 Phase C)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-28.migration.test.ts b/src/extension-api-v2/__tests__/bc-28.migration.test.ts new file mode 100644 index 0000000000..bbf851d8af --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-28.migration.test.ts @@ -0,0 +1,39 @@ +// Category: BC.28 — Subgraph fan-out via set/get virtual nodes +// DB cross-ref: S9.SG1 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406 +// blast_radius: 4.97 +// compat-floor: blast_radius ≥ 2.0 +// migration: isVirtualNode=true + graphToPrompt monkey-patch → defineNodeExtension({ virtual: true, resolveConnections }) +// Decision: I-UWF.5 (2026-05-08) — S8.P1 → virtual: true (mechanical rename); S9.SG1 → add resolveConnections. +// Classified uwf-resolved per I-PG.B2 — UWF Phase 3 is the migration path. + +import { describe, it } from 'vitest' + +describe('BC.28 migration — subgraph fan-out via set/get virtual nodes', () => { + describe('S8.P1 — isVirtualNode flag migration', () => { + it.todo( + 'v1 class-level isVirtualNode=true is replaced by defineNodeExtension({ virtual: true, resolveConnections })' + ) + it.todo( + 'v2 compat shim recognizes isVirtualNode=true on a registered class and emits a migration warning' + ) + it.todo( + 'migration is mechanical: rename isVirtualNode=true to virtual: true and add resolveConnections stub' + ) + }) + + describe('S9.SG1 — graphToPrompt monkey-patch migration', () => { + it.todo( + 'v1 graphToPrompt patch that rewrites link.target_id is replaced by resolveConnections returning ResolvedEdges' + ) + it.todo( + 'v2 resolveConnections receives the same graph state that v1 graphToPrompt received, as a read-only view' + ) + it.todo( + 'v2 compat shim logs a deprecation warning when graphToPrompt is monkey-patched for virtual node resolution' + ) + it.todo( + 'for cg-use-everywhere topology inference (graph-wide, not per-type): ctx.on("beforePrompt") is the bridge until UWF Phase 3' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-28.v1.test.ts b/src/extension-api-v2/__tests__/bc-28.v1.test.ts new file mode 100644 index 0000000000..b1dc2c9b4e --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-28.v1.test.ts @@ -0,0 +1,35 @@ +// Category: BC.28 — Subgraph fan-out via set/get virtual nodes +// DB cross-ref: S9.SG1 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406 +// blast_radius: 4.97 +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: custom virtual node classes with isVirtualNode=true + graphToPrompt rewriting +// to resolve set/get references + +import { describe, it } from 'vitest' + +describe('BC.28 v1 contract — subgraph fan-out via set/get virtual nodes', () => { + describe('S9.SG1 — virtual node registration and isVirtualNode flag', () => { + it.todo( + 'registering a node class with isVirtualNode=true excludes it from prompt serialization' + ) + it.todo( + 'virtual Set node stores a named value in a global registry keyed by node title' + ) + it.todo( + 'virtual Get node reads from the same named registry and wires its output as if linked' + ) + }) + + describe('S9.SG1 — graphToPrompt rewriting', () => { + it.todo( + 'graphToPrompt resolves all Get references to the corresponding Set node output before serialization' + ) + it.todo( + 'multiple Get nodes referencing the same Set name all resolve to the same upstream value' + ) + it.todo( + 'a Get node with no matching Set name is flagged as an error during graphToPrompt' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-28.v2.test.ts b/src/extension-api-v2/__tests__/bc-28.v2.test.ts new file mode 100644 index 0000000000..398e243feb --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-28.v2.test.ts @@ -0,0 +1,42 @@ +// Category: BC.28 — Subgraph fan-out via set/get virtual nodes +// DB cross-ref: S9.SG1 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406 +// blast_radius: 4.97 +// compat-floor: blast_radius ≥ 2.0 +// v2 contract: defineNodeExtension({ virtual: true, resolveConnections(node, graph) → ResolvedEdges }) +// Decision: I-UWF.5 (2026-05-08) — Option (b) accepted. Phase B only. +// resolveConnections is pure; runtime materializes edges at save time (UWF Phase 3). + +import { describe, it } from 'vitest' + +describe('BC.28 v2 contract — subgraph fan-out via set/get virtual nodes', () => { + describe('S9.SG1 — virtual: true declaration', () => { + it.todo( + 'defineNodeExtension({ virtual: true }) excludes the node from spec.edges in the serialized prompt' + ) + it.todo( + 'virtual nodes do not appear in the serialized workflow output keyed by node id' + ) + it.todo( + 'virtual: true without resolveConnections is a type error at registration time' + ) + }) + + describe('S9.SG1 — resolveConnections(node, graph) → ResolvedEdges', () => { + it.todo( + 'resolveConnections receives a read-only view of the virtual node and the full graph' + ) + it.todo( + 'resolveConnections returns an array of { from: NodeSlotRef, to: NodeSlotRef } real edges' + ) + it.todo( + 'runtime calls resolveConnections for every virtual node during spec materialization at save time' + ) + it.todo( + 'resolveConnections returning an empty array removes this virtual node from the spec entirely' + ) + it.todo( + 'resolveConnections must be pure — mutations to node or graph throw in development mode' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-29.migration.test.ts b/src/extension-api-v2/__tests__/bc-29.migration.test.ts new file mode 100644 index 0000000000..dcece24c2f --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-29.migration.test.ts @@ -0,0 +1,40 @@ +// Category: BC.29 — Graph enumeration, mutation, and cross-scope identity +// DB cross-ref: S11.G2, S14.ID1 +// Exemplar: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easyExtraMenu.js#L439 +// blast_radius: 5.13 +// compat-floor: blast_radius ≥ 2.0 +// migration: app.graph raw methods → comfyApp.graph typed API; parseNodeLocatorId → NodeLocatorId.parse + +import { describe, it } from 'vitest' + +describe('BC.29 migration — graph enumeration, mutation, and cross-scope identity', () => { + describe('graph enumeration migration', () => { + it.todo( + 'app.graph.findNodesByType(type) is replaced by comfyApp.graph.findByType(type) returning NodeHandle[]' + ) + it.todo( + 'v2 compat shim forwards app.graph.findNodesByType calls to comfyApp.graph.findByType with a deprecation warning' + ) + }) + + describe('graph mutation migration', () => { + it.todo( + 'app.graph.add(node) accepting a raw LiteGraph node is replaced by comfyApp.graph.addNode(opts)' + ) + it.todo( + 'app.graph.remove(node) accepting a raw reference is replaced by comfyApp.graph.removeNode(handle)' + ) + it.todo( + 'v2 compat shim wraps a raw LiteGraph node passed to add() as a NodeHandle automatically' + ) + }) + + describe('cross-scope identity migration', () => { + it.todo( + 'parseNodeLocatorId(id) free function is replaced by NodeLocatorId.parse(id) static method' + ) + it.todo( + 'createNodeLocatorId(scope, id) is replaced by NodeLocatorId.create(scope, id)' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-29.v1.test.ts b/src/extension-api-v2/__tests__/bc-29.v1.test.ts new file mode 100644 index 0000000000..15aeefe6a4 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-29.v1.test.ts @@ -0,0 +1,40 @@ +// Category: BC.29 — Graph enumeration, mutation, and cross-scope identity +// DB cross-ref: S11.G2, S14.ID1 +// Exemplar: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easyExtraMenu.js#L439 +// blast_radius: 5.13 +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: app.graph.findNodesByType, app.graph.add/remove, parseNodeLocatorId, createNodeLocatorId + +import { describe, it } from 'vitest' + +describe('BC.29 v1 contract — graph enumeration, mutation, and cross-scope identity', () => { + describe('S11.G2 — graph enumeration and mutation', () => { + it.todo( + 'app.graph.findNodesByType("NodeType") returns an array of all matching LiteGraph nodes' + ) + it.todo( + 'app.graph.add(node) inserts a pre-constructed LiteGraph node into the live graph' + ) + it.todo( + 'app.graph.remove(node) removes a node from the live graph by reference' + ) + it.todo( + 'app.graph.serialize() produces a JSON-serializable object representing the full graph state' + ) + it.todo( + 'app.graph.configure(json) restores graph state from a previously serialized object' + ) + }) + + describe('S14.ID1 — cross-subgraph identity helpers', () => { + it.todo( + 'parseNodeLocatorId(id) splits a locator string into { scope, localId } parts' + ) + it.todo( + 'createNodeLocatorId(scope, localId) produces a stable colon-delimited locator string' + ) + it.todo( + 'round-tripping createNodeLocatorId → parseNodeLocatorId recovers the original scope and localId' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-29.v2.test.ts b/src/extension-api-v2/__tests__/bc-29.v2.test.ts new file mode 100644 index 0000000000..dceb14a624 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-29.v2.test.ts @@ -0,0 +1,37 @@ +// Category: BC.29 — Graph enumeration, mutation, and cross-scope identity +// DB cross-ref: S11.G2, S14.ID1 +// Exemplar: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easyExtraMenu.js#L439 +// blast_radius: 5.13 +// compat-floor: blast_radius ≥ 2.0 +// v2 contract: comfyApp.graph.findByType, addNode, removeNode; NodeLocatorId helpers stable + +import { describe, it } from 'vitest' + +describe('BC.29 v2 contract — graph enumeration, mutation, and cross-scope identity', () => { + describe('S11.G2 — graph enumeration and mutation', () => { + it.todo( + 'comfyApp.graph.findByType(type) returns an array of NodeHandle objects for matching nodes' + ) + it.todo( + 'comfyApp.graph.addNode(opts) creates and inserts a new node, returning its NodeHandle' + ) + it.todo( + 'comfyApp.graph.removeNode(handle) removes the node identified by the given NodeHandle' + ) + it.todo( + 'comfyApp.graph.serialize() returns the same JSON-compatible format as v1 for round-trip compatibility' + ) + }) + + describe('S14.ID1 — cross-subgraph identity helpers', () => { + it.todo( + 'NodeLocatorId.parse(id) returns a typed { scope, localId } object' + ) + it.todo( + 'NodeLocatorId.create(scope, localId) returns a stable string compatible with v1 parseNodeLocatorId output' + ) + it.todo( + 'NodeExecutionId is distinct from NodeLocatorId and reflects runtime execution scope, not graph scope' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-30.migration.test.ts b/src/extension-api-v2/__tests__/bc-30.migration.test.ts new file mode 100644 index 0000000000..bf44d62cdb --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-30.migration.test.ts @@ -0,0 +1,40 @@ +// Category: BC.30 — Graph change tracking, batching, and reactivity flush +// DB cross-ref: S11.G1, S11.G3, S11.G4 +// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1 +// blast_radius: 5.48 +// compat-floor: blast_radius ≥ 2.0 +// migration: graph._version / beforeChange / afterChange / setDirtyCanvas → Vue reactivity + batchUpdate + +import { describe, it } from 'vitest' + +describe('BC.30 migration — graph change tracking, batching, and reactivity flush', () => { + describe('_version counter migration', () => { + it.todo( + 'extensions that increment graph._version to signal changes should switch to comfyApp.graph.batchUpdate()' + ) + it.todo( + 'v2 compat shim intercepts graph._version++ and logs a deprecation warning' + ) + }) + + describe('beforeChange / afterChange migration', () => { + it.todo( + 'graph.beforeChange() + graph.afterChange() pairs are replaced by comfyApp.graph.batchUpdate(fn)' + ) + it.todo( + 'v2 compat shim stubs beforeChange/afterChange as no-ops and logs deprecation warnings' + ) + it.todo( + 'code relying on nested beforeChange ref-counting must be refactored to nested batchUpdate calls' + ) + }) + + describe('setDirtyCanvas migration', () => { + it.todo( + 'node.setDirtyCanvas(true, true) calls are safe to remove in v2 — reactivity handles repaints' + ) + it.todo( + 'v2 compat shim stubs setDirtyCanvas as a no-op with a deprecation warning rather than throwing' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-30.v1.test.ts b/src/extension-api-v2/__tests__/bc-30.v1.test.ts new file mode 100644 index 0000000000..98a0ff6943 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-30.v1.test.ts @@ -0,0 +1,43 @@ +// Category: BC.30 — Graph change tracking, batching, and reactivity flush +// DB cross-ref: S11.G1, S11.G3, S11.G4 +// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1 +// blast_radius: 5.48 +// compat-floor: blast_radius ≥ 2.0 +// v1 contract: graph._version++, graph.beforeChange(), graph.afterChange(), node.setDirtyCanvas(true, true) + +import { describe, it } from 'vitest' + +describe('BC.30 v1 contract — graph change tracking, batching, and reactivity flush', () => { + describe('S11.G1 — _version monotonic counter', () => { + it.todo( + 'graph._version is a numeric property that increments with each structural change' + ) + it.todo( + 'extension can increment graph._version to signal a change and trigger downstream listeners' + ) + it.todo( + 'reading graph._version before and after a node add/remove shows the value increased' + ) + }) + + describe('S11.G3 — beforeChange / afterChange batching', () => { + it.todo( + 'calling graph.beforeChange() suspends incremental canvas redraws' + ) + it.todo( + 'calling graph.afterChange() after a batch of mutations triggers a single consolidated redraw' + ) + it.todo( + 'nested beforeChange/afterChange calls are ref-counted and only flush on the outermost afterChange' + ) + }) + + describe('S11.G4 — setDirtyCanvas imperative flush', () => { + it.todo( + 'node.setDirtyCanvas(true, false) marks the foreground canvas dirty and schedules a repaint' + ) + it.todo( + 'node.setDirtyCanvas(true, true) marks both foreground and background canvases dirty' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-30.v2.test.ts b/src/extension-api-v2/__tests__/bc-30.v2.test.ts new file mode 100644 index 0000000000..e0318ba598 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-30.v2.test.ts @@ -0,0 +1,44 @@ +// Category: BC.30 — Graph change tracking, batching, and reactivity flush +// DB cross-ref: S11.G1, S11.G3, S11.G4 +// Exemplar: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1 +// blast_radius: 5.48 +// compat-floor: blast_radius ≥ 2.0 +// v2 contract: Vue reactivity replaces graph._version; comfyApp.graph.batchUpdate(fn) replaces +// beforeChange/afterChange; setDirtyCanvas is implicit + +import { describe, it } from 'vitest' + +describe('BC.30 v2 contract — graph change tracking, batching, and reactivity flush', () => { + describe('S11.G1 — reactive graph state replaces _version', () => { + it.todo( + 'graph state is Vue-reactive; watchers on graph node count or structure auto-trigger without _version polling' + ) + it.todo( + 'graph._version does not exist on the v2 GraphHandle; accessing it returns undefined' + ) + it.todo( + 'comfyApp.graph exposes a reactive nodeCount property that updates when nodes are added or removed' + ) + }) + + describe('S11.G3 — batchUpdate replaces beforeChange/afterChange', () => { + it.todo( + 'comfyApp.graph.batchUpdate(fn) defers all reactive updates until fn completes' + ) + it.todo( + 'mutations inside batchUpdate are committed atomically; watchers see only the post-batch state' + ) + it.todo( + 'exceptions thrown inside batchUpdate cause the batch to be rolled back with no partial state visible' + ) + }) + + describe('S11.G4 — implicit canvas flush', () => { + it.todo( + 'setDirtyCanvas is not needed in v2 — Vue reactivity and the render loop coordinate repaints automatically' + ) + it.todo( + 'calling node.setDirtyCanvas in v2 is a no-op shim that logs a deprecation warning' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-31.migration.test.ts b/src/extension-api-v2/__tests__/bc-31.migration.test.ts new file mode 100644 index 0000000000..6560d7e348 --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-31.migration.test.ts @@ -0,0 +1,44 @@ +// Category: BC.31 — DOM injection and style management +// DB cross-ref: S16.DOM1, S16.DOM2, S16.DOM3, S16.DOM4 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js +// Migration: v1 raw DOM injection → v2 injectStyles / addPanel / addToolbarItem + +import { describe, it } from 'vitest' + +describe('BC.31 migration — DOM injection and style management', () => { + describe('style injection migration (S16.DOM1)', () => { + it.todo( + 'v1 document.head.appendChild(styleEl) and v2 injectStyles(css) both result in equivalent CSS applied to document' + ) + it.todo( + 'v2 injectStyles() produces styles with equal or narrower specificity than v1 raw injection' + ) + }) + + describe('panel injection migration (S16.DOM2)', () => { + it.todo( + 'v1 document.body.appendChild(el) and v2 addPanel() both result in a panel element present in the DOM' + ) + it.todo( + 'v2 panel is visible in same position as v1 body-appended element for equivalent opts' + ) + }) + + describe('HTML content migration (S16.DOM3)', () => { + it.todo( + 'content rendered via v1 innerHTML and v2 safe rendering API produces equivalent visible output for trusted HTML' + ) + it.todo( + 'v2 safe rendering API blocks XSS payloads that v1 innerHTML would have executed' + ) + }) + + describe('scope cleanup on unregister', () => { + it.todo( + 'v1 style/panel injections persist after extension unregisters (no cleanup); v2 injections are removed' + ) + it.todo( + 'v2 cleanup on unregister does not affect styles/panels from other extensions' + ) + }) +}) diff --git a/src/extension-api-v2/__tests__/bc-31.v1.test.ts b/src/extension-api-v2/__tests__/bc-31.v1.test.ts new file mode 100644 index 0000000000..8beb91a21f --- /dev/null +++ b/src/extension-api-v2/__tests__/bc-31.v1.test.ts @@ -0,0 +1,48 @@ +// Category: BC.31 — DOM injection and style management +// DB cross-ref: S16.DOM1, S16.DOM2, S16.DOM3, S16.DOM4 +// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js +// Surface: S16 — DOM injection (new surface family, not previously tracked) +// Occurrence signal: DOM1=354, DOM2=364, DOM3=443, DOM4=232 packages (Notion API research 2026-05-08) + +import { describe, it } from 'vitest' + +describe('BC.31 v1 contract — DOM injection and style management', () => { + describe('S16.DOM1 — style tag injection into document.head', () => { + it.todo( + 'extension can append a