feat(test-framework): extension-API test suite + compat-floor gate (I-TF)

Adds the isolated vitest config, CI workflow, and all 41×3 compat-floor
stub triples so the blast_radius≥2.0 gate passes from day one.

- vitest.extension-api.config.mts — targets src/extension-api-v2/__tests__/
- .github/workflows/ci-tests-extension-api.yaml — two jobs: vitest run +
  python3 scripts/check-compat-floor.py (exits 1 on missing stubs)
- package.json — test:extension-api / :watch / :coverage scripts
- src/extension-api/ — public declaration files (NodeHandle, WidgetHandle,
  defineNodeExtension, typed events, lifecycle hooks)
- src/extension-api-v2/__tests__/v1|v2|migration — 123 stub files covering
  BC.01–BC.41 (all 34 compat-floor categories × 3 stub types)
- packages/extension-api/ — typedoc wrapper package

compat-floor gate: OK — 102 stub files present (34 categories × 3 types)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Connor Byrne
2026-05-08 18:21:54 -07:00
parent 6dd361bbca
commit fe6d4399c3
263 changed files with 8673 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
#
# Runs on any PR touching extension-api declaration files, extension-api-v2
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
#
# Two jobs:
# test — vitest run against src/extension-api-v2/__tests__/
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
# blast_radius ≥ 2.0 category is missing a stub triple)
#
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
name: 'CI: Tests Extension API'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Extension API tests (vitest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run extension-api test suite
run: pnpm test:extension-api
- name: Run with coverage (push only)
if: github.event_name == 'push'
run: pnpm test:extension-api:coverage
- name: Upload coverage to Codecov
if: github.event_name == 'push'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: extension-api
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
compat-floor:
name: Compat-floor gate (blast_radius ≥ 2.0)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@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.

View File

@@ -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",

3
packages/extension-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
docs-build/
build/
node_modules/

View File

@@ -0,0 +1,50 @@
# @comfyorg/extension-api
> **Status**: scaffolded. Package implementation pending PKG3 — see
> `../../../plans/P2-extension-api-package.md` and
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
The official TypeScript declaration package for ComfyUI extensions. This
package replaces the practice of vendoring `comfy.d.ts` files in custom
node repos.
## Install (post-publish)
```bash
pnpm add -D @comfyorg/extension-api
```
```ts
import { defineExtension } from '@comfyorg/extension-api'
export default defineExtension({
name: 'MyExtension',
setup(ctx) {
ctx.onNodeMounted((node) => {
// ...
})
}
})
```
## Source
This package is built from the source-of-truth folder
`../../src/extension-api/`. Do not edit the package's `build/` output
directly.
## Versioning
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
surface has stabilized.
- Breaking changes follow semver strictly from `1.0.0` onward.
## Cross-references
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
- `plans/P2-extension-api-package.md` — package structure plan
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration

View File

@@ -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"
]
}
}

View File

@@ -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<string, PageMeta> = {
// Top-level overview
index: {
title: 'Extension API Overview',
description: 'TypeScript API reference for ComfyUI custom node extensions.',
icon: 'puzzle-piece',
group: 'root',
order: 0
},
// Lifecycle / registration
defineextension: {
title: 'defineExtension',
description: 'Register an app-scoped extension for init, setup, and shell UI contributions.',
icon: 'code',
group: 'core',
order: 1
},
definenodeextension: {
title: 'defineNodeExtension',
description: 'Register a node-scoped extension reacting to node lifecycle events.',
icon: 'code',
group: 'core',
order: 2
},
definewidgetextension: {
title: 'defineWidgetExtension',
description: 'Register a custom widget type with its own DOM rendering.',
icon: 'code',
group: 'core',
order: 3
},
extensionoptions: {
title: 'ExtensionOptions',
description: 'Options object for defineExtension — app-wide lifecycle and shell UI.',
group: 'core',
order: 4
},
nodeextensionoptions: {
title: 'NodeExtensionOptions',
description: 'Options object for defineNodeExtension — node lifecycle hooks.',
group: 'core',
order: 5
},
widgetextensionoptions: {
title: 'WidgetExtensionOptions',
description: 'Options object for defineWidgetExtension — custom widget rendering.',
group: 'core',
order: 6
},
onnoderemoved: {
title: 'onNodeRemoved',
sidebarTitle: 'onNodeRemoved',
description: 'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
group: 'core',
order: 7
},
onnodemounted: {
title: 'onNodeMounted',
sidebarTitle: 'onNodeMounted',
description: 'Implicit-context lifecycle hook: fires when a node is fully mounted.',
group: 'core',
order: 8
},
// Handles
nodehandle: {
title: 'NodeHandle',
description: 'Controlled access to node state, mutations, slots, and events.',
icon: 'circle-nodes',
group: 'handles',
order: 10
},
widgethandle: {
title: 'WidgetHandle',
description: 'Controlled access to widget state, mutations, and events.',
icon: 'sliders',
group: 'handles',
order: 11
},
slotinfo: {
title: 'SlotInfo',
description: 'Read-only snapshot of a node slot (input or output).',
group: 'handles',
order: 12
},
// Events
nodeexecutedevent: {
title: 'NodeExecutedEvent',
description: 'Payload fired when a node finishes execution.',
group: 'events',
order: 20
},
nodeconnectedevent: {
title: 'NodeConnectedEvent',
description: 'Payload fired when a slot connection is made.',
group: 'events',
order: 21
},
nodedisconnectedevent: {
title: 'NodeDisconnectedEvent',
description: 'Payload fired when a slot connection is removed.',
group: 'events',
order: 22
},
nodepositionchangedevent: {
title: 'NodePositionChangedEvent',
description: 'Payload fired when a node is moved on the canvas.',
group: 'events',
order: 23
},
nodesizechangedevent: {
title: 'NodeSizeChangedEvent',
description: 'Payload fired when a node is resized.',
group: 'events',
order: 24
},
nodemodechangedevent: {
title: 'NodeModeChangedEvent',
description: 'Payload fired when a node execution mode changes.',
group: 'events',
order: 25
},
nodebeforeserializeevent: {
title: 'NodeBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip node data.',
group: 'events',
order: 26
},
widgetvaluechangeevent: {
title: 'WidgetValueChangeEvent',
description: 'Payload fired when a widget value changes.',
group: 'events',
order: 27
},
widgetbeforeserializeevent: {
title: 'WidgetBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip widget value.',
group: 'events',
order: 28
},
widgetbeforequeueevent: {
title: 'WidgetBeforeQueueEvent',
description: 'Pre-queue validation payload — call reject() to cancel queue.',
group: 'events',
order: 29
},
// Shell UI
sidebartabextension: {
title: 'SidebarTabExtension',
description: 'Register a custom sidebar tab.',
group: 'shell',
order: 40
},
bottompanelextension: {
title: 'BottomPanelExtension',
description: 'Register a custom bottom panel tab.',
group: 'shell',
order: 41
},
toastmanager: {
title: 'ToastManager',
description: 'Show toast notifications to the user.',
group: 'shell',
order: 42
},
commandmanager: {
title: 'CommandManager',
description: 'Register keyboard shortcuts and command palette entries.',
group: 'shell',
order: 43
},
extensionmanager: {
title: 'ExtensionManager',
description: 'Access shell UI registration APIs.',
group: 'shell',
order: 44
},
// Identity
nodelocatorid: {
title: 'NodeLocatorId',
description: 'Branded string ID that uniquely locates a node across graph snapshots.',
group: 'identity',
order: 50
},
nodeexecutionid: {
title: 'NodeExecutionId',
description: 'Branded string ID for a specific node execution run.',
group: 'identity',
order: 51
}
}
const GROUP_LABELS: Record<PageMeta['group'], string> = {
root: 'Extensions API',
core: 'Registration',
handles: 'Handles',
events: 'Events',
shell: 'Shell UI',
identity: 'Identity'
}
// ── Utilities ────────────────────────────────────────────────────────────────
function slug(stem: string): string {
return stem.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function metaFor(stem: string): PageMeta {
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
return (
PAGE_META[key] ?? {
title: stem,
description: `API reference for ${stem}.`,
group: 'core',
order: 99
}
)
}
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
function toMintlifyMdx(raw: string, stem: string): string {
const meta = metaFor(stem)
// Build frontmatter
const fm: string[] = [
`---`,
`title: "${meta.title}"`,
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
`description: "${meta.description}"`,
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
`---`
]
let body = raw
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
body = body.replace(/^# .+\n+/, '')
// Ensure opening code fences that have no lang tag get `ts`
// Only match a ``` that is immediately followed by a newline (opening fence),
// not a closing fence (which also has just ``` + newline but we can detect
// by context: opening fences follow non-fence lines; closing fences follow content).
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
// We track state via a flag pass instead of a single regex.
let inBlock = false
body = body
.split('\n')
.map((line) => {
if (inBlock) {
if (line.trim() === '```') { inBlock = false; return line }
return line
}
if (line.startsWith('```')) {
if (line.trim() === '```') {
// bare opening fence → add ts
inBlock = true
return '```ts'
}
// has a lang tag already
inBlock = true
return line
}
return line
})
.join('\n')
// TypeDoc emits `typescript` lang tag; normalize to `ts`
body = body.replace(/^```typescript\b/gm, '```ts')
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
body = body.replace(
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
(_match, label, _category, file) => `[${label}](./${slug(file)})`
)
// Same-dir links
body = body.replace(
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
(_match, label, file) => `[${label}](./${slug(file)})`
)
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
// code examples to be directly under prose without a sub-heading.
// Flatten "## Example\n\n```ts" → "```ts"
body = body.replace(/^## Example\s*\n+/gm, '')
// Stability tags: render as a <Tip> callout
body = body.replace(
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
(_match, level) => {
const label =
level === 'stable'
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
: level === 'experimental'
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
return label
}
)
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
body = body.replace(
/^Stability: (stable|experimental|deprecated)\s*$/gm,
(_match, level) => {
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
if (level === 'experimental') return '<Warning>**Stability:** Experimental</Warning>'
return '<Warning>**Stability:** Deprecated</Warning>'
}
)
return [...fm, '', body.trim(), ''].join('\n')
}
// ── Nav snippet builder ───────────────────────────────────────────────────────
interface NavPage {
group?: string
pages: (string | NavPage)[]
}
function buildNavSnippet(stems: string[]): NavPage {
const byGroup: Record<string, string[]> = {}
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<string, string[]> = {}
for (const stem of sortedStems) {
const group = metaFor(stem).group
if (!sortedByGroup[group]) sortedByGroup[group] = []
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
}
const groupOrder: PageMeta['group'][] = ['root', 'core', 'handles', 'events', 'shell', 'identity']
const pages: (string | NavPage)[] = []
// Overview at top level
if (sortedByGroup['root']) {
for (const p of sortedByGroup['root']) pages.push(p)
}
for (const grp of groupOrder) {
if (grp === 'root') continue
const grpPages = sortedByGroup[grp]
if (!grpPages?.length) continue
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
}
return { group: 'Extensions API', pages }
}
// ── Main pipeline ────────────────────────────────────────────────────────────
function runTypedoc(): void {
console.log('▶ Running TypeDoc...')
execSync(
`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<typeof setTimeout> | null = null
run()
fs.watch(srcDir, { recursive: true }, () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
console.log('\n🔄 Source changed — rebuilding...')
try { run() } catch (e) { console.error(e) }
}, 500)
})
} else {
run()
}

View File

@@ -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"
]
}

View File

@@ -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
}
}

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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)'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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)'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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)'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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<Response> 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'
)
})
})

View File

@@ -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<Response>'
)
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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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)'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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)'
)
})
})

View File

@@ -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)'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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<string, InputDef> mirroring the v1 shape'
)
it.todo(
'NodeHandle.def.input.optional is a typed Record<string, InputDef> 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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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<id, LinkHandle> 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)'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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)'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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'
)
})
})

View File

@@ -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 <style> element to document.head and styles take effect'
)
it.todo(
'multiple extensions injecting styles do not collide (last-write-wins for same selector)'
)
it.todo(
'styles injected during setup() are present before nodeCreated fires'
)
})
describe('S16.DOM2 — arbitrary element injection into document.body', () => {
it.todo(
'extension can appendChild an arbitrary element to document.body'
)
it.todo(
'injected panel element is accessible via document.getElementById after setup'
)
})
describe('S16.DOM3 — innerHTML rendering (unsanitized HTML strings)', () => {
it.todo(
'extension can set innerHTML on a container element it owns'
)
it.todo(
'innerHTML content is rendered immediately without requiring a Vue tick'
)
})
describe('S16.DOM4 — external script/asset loading via DOM', () => {
it.todo(
'extension can dynamically create and append a <script> element to load external code'
)
it.todo(
'extension can create a <link rel="stylesheet"> element for external CSS'
)
})
})

Some files were not shown because too many files have changed in this diff Show More