Compare commits

..

5 Commits

Author SHA1 Message Date
Connor Byrne
9bca2e3559 fix: rewrite credit helpers E2E test to match actual component behavior
- In NodeSearchContent, showDescription=true means NodePricingBadge is
  NOT rendered (only provider badges). Adjusted search test to verify
  API node indicator instead of pricing badge.
- Split VueNodes header test into separate tests for each pricing type
  (USD, range, list) for better isolation and clearer failures.
- Fixed icon class selector from non-existent `comfy--credits` to actual
  `lucide--component` used by CreditBadge.
- Added proper setup: clearGraph, enable ShowApiPricing setting.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11578#pullrequestreview-2917421451

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 12:17:37 -07:00
Connor Byrne
9e1d72a625 Merge remote-tracking branch 'origin/main' into glary/add-credit-helpers-e2e-test 2026-05-11 13:21:09 -07:00
Alexander Brown
ced7c93e63 testing: Improve custom checks in .coderabbit.yaml (#12141)
Update coderabbit end-to-end check logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12141-testing-Improve-custom-checks-in-coderabbit-yaml-35d6d73d3650818e8be2f0b7d403683b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 19:05:48 +00:00
Christian Byrne
2e0bedce12 Merge branch 'main' into glary/add-credit-helpers-e2e-test 2026-05-11 11:52:02 -07:00
Glary-Bot
61b431a79f test: add coverage for credit helper functions
- Unit tests: cover line 25 minimumFractionDigits guard and clampUsd
- E2E tests: verify pricing badge rendering via node search and VueNodes
  graph header, covering formatCredits, CREDITS_PER_USD, and formatNumber
  through USD, range, and list pricing result types
2026-05-04 22:47:36 +00:00
235 changed files with 425 additions and 39408 deletions

View File

@@ -19,15 +19,26 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |

View File

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

View File

@@ -1,97 +0,0 @@
# Description: Publish @comfyorg/extension-api to npm with provenance attestation.
#
# Triggered by a tag push matching 'extension-api-v*' (e.g. extension-api-v0.1.0).
# Also supports workflow_dispatch for a manual dry-run (set dry_run: true).
#
# Prerequisites (one-time human setup):
# - NPM_TOKEN secret must be set in the repo/org settings with publish
# access to the @comfyorg scope on npmjs.com.
# - The @comfyorg npm scope already exists (used by @comfyorg/comfyui-frontend).
#
# PKG4.D4 (MIG1 / Phase A — surface-only shim)
name: 'Extension API: Publish'
on:
push:
tags:
- 'extension-api-v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run — build and verify without publishing'
required: false
default: true
type: boolean
permissions:
contents: write # needed to create GitHub Release
id-token: write # needed for npm provenance via OIDC
jobs:
publish:
name: Publish @comfyorg/extension-api
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history for release notes
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Setup npm registry
uses: actions/setup-node@v6
with:
registry-url: 'https://registry.npmjs.org/'
- name: Build package
run: pnpm --filter @comfyorg/extension-api build
- name: Typecheck package
run: pnpm --filter @comfyorg/extension-api typecheck
- name: Verify package version matches tag
if: github.event_name == 'push'
run: |
TAG="${GITHUB_REF_NAME}" # e.g. extension-api-v0.1.0
PKG_VERSION=$(node -p "require('./packages/extension-api/package.json').version")
TAG_VERSION="${TAG#extension-api-v}" # strip prefix → 0.1.0
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "::error::Tag '$TAG' implies version '$TAG_VERSION' but packages/extension-api/package.json has '$PKG_VERSION'. Update the package.json before tagging."
exit 1
fi
echo "Version check passed: $PKG_VERSION"
- name: Publish to npm (with provenance)
if: github.event_name == 'push' || !inputs.dry_run
run: |
cd packages/extension-api
npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Dry-run report
if: inputs.dry_run
run: |
echo "=== DRY RUN — would publish ==="
cd packages/extension-api
npm pack --dry-run
echo "=== End dry run ==="
- name: Create GitHub Release
if: github.event_name == 'push'
uses: actions/github-script@v8
with:
script: |
const tag = context.ref.replace('refs/tags/', '')
const { data: release } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name: tag,
generate_release_notes: true,
draft: false,
prerelease: context.ref.includes('-alpha') || context.ref.includes('-beta') || context.ref.includes('-rc')
})
console.log(`Release created: ${release.html_url}`)

View File

@@ -1,65 +0,0 @@
# Description: Typecheck and build the @comfyorg/extension-api package.
# Runs on PRs and pushes touching the public type surface, the core .v2.ts
# implementations, or the package scaffold — so regressions in the published
# contract are caught before merge.
#
# PKG4.D3 (MIG1 / Phase A — surface-only shim)
name: 'Extension API: Typecheck'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extensions/core/*.v2.ts'
- 'src/services/extension-api-service.ts'
- 'packages/extension-api/**'
- '.github/workflows/extension-api-*.yml'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extensions/core/*.v2.ts'
- 'src/services/extension-api-service.ts'
- 'packages/extension-api/**'
- '.github/workflows/extension-api-*.yml'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
typecheck:
name: Build + typecheck @comfyorg/extension-api
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build package (emit declarations)
run: pnpm --filter @comfyorg/extension-api build
- name: Typecheck package
run: pnpm --filter @comfyorg/extension-api typecheck
- name: Smoke-test consumer (tsc --noEmit on minimal extension)
# Verifies the published types are consumable from an external module
# that imports from '@comfyorg/extension-api'. Uses a minimal fixture
# checked in to packages/extension-api/test/smoke/.
run: |
cd packages/extension-api
if [ -d test/smoke ]; then
pnpm exec tsc --noEmit --project test/smoke/tsconfig.json
else
echo "No smoke test found — skipping (add packages/extension-api/test/smoke/ to enable)"
fi

View File

@@ -6,7 +6,6 @@
"trailingComma": "none",
"printWidth": 80,
"ignorePatterns": [
"packages/extension-api/build/**",
"packages/registry-types/src/comfyRegistryTypes.ts",
"public/materialdesignicons.min.css",
"src/types/generatedManagerTypes.ts",

View File

@@ -0,0 +1,217 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockNodeDefinitions } from '@e2e/fixtures/data/nodeDefinitions'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
function buildMockApiNode(
name: string,
displayName: string,
expr: string
): ComfyNodeDef {
return {
name,
display_name: displayName,
description: 'Test API node for pricing badge checks',
category: 'testing',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE'],
output_is_list: [false],
output_name: ['IMAGE'],
output_node: false,
python_module: 'test_nodes',
deprecated: false,
experimental: false,
api_node: true,
price_badge: {
engine: 'jsonata',
expr,
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
}
}
const MOCK_API_NODES: Record<string, ComfyNodeDef> = {
TestCreditApiNodeUsd: buildMockApiNode(
'TestCreditApiNodeUsd',
'Test Credit API Node USD',
'{"type":"usd","usd":0.05}'
),
TestCreditApiNodeRange: buildMockApiNode(
'TestCreditApiNodeRange',
'Test Credit API Node Range',
'{"type":"range_usd","min_usd":0.01,"max_usd":0.10}'
),
TestCreditApiNodeList: buildMockApiNode(
'TestCreditApiNodeList',
'Test Credit API Node List',
'{"type":"list_usd","usd":[0.02,0.05]}'
)
}
const testWithMockedObjectInfo = test.extend<{ mockApiNodes: void }>({
mockApiNodes: [
async ({ page }, use) => {
const nodeDefs = createMockNodeDefinitions(MOCK_API_NODES)
const pattern = '**/api/object_info'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(nodeDefs)
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
testWithMockedObjectInfo.describe(
'Credit helper pricing badges',
{ tag: '@node' },
() => {
testWithMockedObjectInfo.use({ locale: 'en-US' })
testWithMockedObjectInfo.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
})
testWithMockedObjectInfo(
'shows API node indicator in node search results',
async ({ comfyPage }) => {
await comfyPage.canvasOps.doubleClick()
await expect(comfyPage.searchBoxV2.input).toBeVisible()
await comfyPage.searchBoxV2.input.fill('TestCreditApiNodeUsd')
const result = comfyPage.searchBoxV2.results
.filter({ hasText: 'Test Credit API Node USD' })
.first()
await expect(result).toBeVisible()
// In search results with showDescription=true, the component icon is shown
// (not the pricing badge). Verify the API node indicator is present.
const apiIndicator = result.locator('i[class*="lucide--component"]')
await expect(apiIndicator).toBeVisible()
}
)
testWithMockedObjectInfo(
'shows pricing badge in VueNodes node header',
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.ShowApiPricing',
true
)
await comfyPage.nodeOps.clearGraph()
const nodeId = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('TestCreditApiNodeUsd')
window.app!.graph.add(node!)
return node!.id
})
await comfyPage.vueNodes.waitForNodes(1)
const header = comfyPage.page.locator(
`[data-testid="node-header-${nodeId}"]`
)
await expect(header).toBeVisible()
// CreditBadge uses icon-[lucide--component] for the credits icon
const creditsBadge = header.locator('i[class*="lucide--component"]')
await expect(creditsBadge).toBeVisible()
// Verify the badge text contains expected credit amount (10.6 credits for $0.05)
const badgeContainer = header.locator(
'span:has(> i[class*="lucide--component"])'
)
await expect
.poll(async () => (await badgeContainer.textContent())?.trim() ?? '')
.toContain('10.6')
}
)
testWithMockedObjectInfo(
'shows range pricing in VueNodes node header',
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.ShowApiPricing',
true
)
await comfyPage.nodeOps.clearGraph()
const nodeId = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('TestCreditApiNodeRange')
window.app!.graph.add(node!)
return node!.id
})
await comfyPage.vueNodes.waitForNodes(1)
const header = comfyPage.page.locator(
`[data-testid="node-header-${nodeId}"]`
)
await expect(header).toBeVisible()
// Verify range format (2.1-21.1 credits for $0.01-$0.10)
const badgeContainer = header.locator(
'span:has(> i[class*="lucide--component"])'
)
await expect
.poll(async () => (await badgeContainer.textContent())?.trim() ?? '')
.toContain('2.1-21.1')
}
)
testWithMockedObjectInfo(
'shows list pricing in VueNodes node header',
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.ShowApiPricing',
true
)
await comfyPage.nodeOps.clearGraph()
const nodeId = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('TestCreditApiNodeList')
window.app!.graph.add(node!)
return node!.id
})
await comfyPage.vueNodes.waitForNodes(1)
const header = comfyPage.page.locator(
`[data-testid="node-header-${nodeId}"]`
)
await expect(header).toBeVisible()
// Verify list format (4.2/10.6 credits for [$0.02, $0.05])
const badgeContainer = header.locator(
'span:has(> i[class*="lucide--component"])'
)
await expect
.poll(async () => (await badgeContainer.textContent())?.trim() ?? '')
.toContain('4.2/10.6')
}
)
}
)

View File

@@ -1,108 +0,0 @@
# 10. Deprecate Node-Level Serialization Control
Date: 2026-05-12
## Status
Accepted
## Context
The v2 extension API initially included `node.on('beforeSerialize', handler)` as a migration path from v1 patterns like `node.onSerialize` and `nodeType.prototype.serialize` patching. This allowed extensions to:
1. **Append extra fields** to the serialized node object
2. **Transform the entire serialized object** via a replace function
However, during design review (PR #12142), we questioned whether node-level serialization control is the right abstraction:
### The Problem
Node-level serialization control is fundamentally **wrong-layered**:
- **Extension state should live in widgets**, not as arbitrary fields on the node
- Widget-level `beforeSerialize` already handles all legitimate use cases
- Node-level hooks encourage storing extension state in ad-hoc `node.properties` or custom fields, which:
- Breaks the clean separation between framework concerns and extension concerns
- Creates hidden dependencies between serialization format and extension behavior
- Makes migration and format evolution harder
### v1 Usage Analysis
Touch-point audit of `nodeType.prototype.serialize` and `node.onSerialize` patterns in the wild:
| Use Case | Proper v2 Alternative |
| --------------------------- | --------------------------------------------------- |
| Store extension state | Use widget values with `beforeSerialize` |
| Persist per-instance config | Use `widget.setOption()``widget_options` sidecar |
| Add metadata for export | Use a dedicated extension state widget |
| Transform output format | Framework concern, not extension concern |
No use case requires node-level control that can't be better served by widget-level APIs.
## Decision
**Deprecate `node.on('beforeSerialize')`** — mark as `@deprecated` with clear guidance pointing to widget-level alternatives. Remove in v1.0.
Widget-level serialization control (`widget.on('beforeSerialize')`) remains fully supported as the correct abstraction.
### Migration Path
Extensions currently using `node.on('beforeSerialize')` should:
1. **Store state in widgets** instead of arbitrary node fields
2. **Use `widget.on('beforeSerialize')`** to control serialization per-widget
3. **Use `widget.setOption()`** for per-instance configuration
Example migration:
```ts
// BEFORE (v1 / deprecated v2)
node.on('beforeSerialize', (e) => {
e.data['my_extension_state'] = computeState()
})
// AFTER (recommended v2)
const stateWidget = node.addWidget('STRING', '_my_state', '', {
hidden: true,
serialize: true
})
stateWidget.on('beforeSerialize', (e) => {
e.setSerializedValue(JSON.stringify(computeState()))
})
```
### Implementation Steps
1. Add `@deprecated` tag to `node.on('beforeSerialize')` with migration guidance
2. Add console.warn when the deprecated event is used (dev mode only)
3. Update documentation to recommend widget-level patterns
4. Remove `NodeBeforeSerializeEvent` type and handler in v1.0
## Consequences
### Positive
- **Cleaner architecture**: Extension state flows through widgets, the designed data channel
- **Better debuggability**: Widget values are visible in workflow JSON at predictable locations
- **Easier migration**: Future format changes only need to consider widget serialization
- **Reduced API surface**: One less event type to maintain and document
### Negative
- **Migration burden**: Extensions using node-level serialization must refactor
- **Potential edge cases**: Some exotic use cases may require workarounds
### Risk Mitigation
- Deprecation warning gives extension authors runway to migrate
- Widget-level APIs are already more capable than node-level alternatives
- The `@deprecated` tag and docs provide clear migration path
## Notes
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 11 for the full discussion thread.
Related decisions:
- Widget-level `beforeSerialize` remains the primary extension serialization hook
- `setSerializeEnabled()` remains for simple static opt-out cases

View File

@@ -1,151 +0,0 @@
# 10. Widget State Categories
Date: 2026-05-12
## Status
Proposed
## Context
The current widget system evolved organically and has several architectural issues:
- `options` is a constructor bag that gets reference-assigned, not copied
- Instance properties (`widget.hidden`) and options bag (`widget.options.hidden`) are used interchangeably for the same concept
- No clear separation between schema (type/name), runtime state (value/disabled), display hints (hidden), per-instance config (min/max), and serialization config
- `Object.assign(this, safeValues)` in BaseWidget constructor means arbitrary properties can land on the instance
- The dual `hidden` location causes bugs: Vue renderer reads `options.hidden`, canvas renderer reads `widget.hidden`
The ECS implementation uses 5 separate components (`WidgetComponentValue`, `WidgetComponentDisplay`, `WidgetComponentSchema`, `WidgetComponentSerialize`, `WidgetComponentContainer`), but this granularity is an implementation detail that shouldn't leak into the extension API.
### Forces
- Extensions need a simple, predictable mental model for widget state
- The API should align with familiar patterns (Vue's component model)
- ECS internals should remain hidden behind a facade
- Migration from v1 patterns should be straightforward
- The distinction between "presence of a constraint" (schema) and "value of a constraint" (prop) matters for primitives and subgraph widget merging
## Decision
Widget state is organized into **two categories**:
### Schema (Immutable)
Properties that cannot change after widget construction:
- `type` — widget type string (e.g., `'INT'`, `'STRING'`, `'COMBO'`)
- `name` — widget name as declared in `INPUT_TYPES`
- Presence of constraints (the _fact_ that min/max/step exist)
- Default values
Schema comes from the node definition and is frozen at construction time.
### Props (Mutable, Per-Instance)
Everything else — all per-instance state that can change at runtime:
- `value` — the primary data (like Vue's `modelValue`)
- `disabled`, `hidden`, `label`, `advanced`
- Actual values of `min`, `max`, `step` (presence is schema, values are props)
- `serialize` flag
- `callback`, `draw`, `mouse`, `computeSize` (functions are values in JS)
Props follow one-way data flow: systems mutate props, views observe them.
### Model Value Convention
`value` is special only by convention, not by nature:
- It serializes to workflow JSON (`widgets_values`)
- It goes to the backend in prompts
- It gets an ergonomic `.value` accessor (like Vue's `defineModel()`)
This mirrors Vue's `modelValue` — the prop that `v-model` binds to.
### API Surface
```typescript
interface WidgetHandle<T> {
// Schema (readonly)
readonly name: string
readonly widgetType: string
// Props: value (modelValue) — ergonomic accessor
value: T
getValue(): T // alias
setValue(v: T): void // alias
// Props: common — ergonomic accessors
isHidden(): boolean
setHidden(hidden: boolean): void
isDisabled(): boolean
setDisabled(disabled: boolean): void
// Props: type-specific — via getOption/setOption
getOption<K>(key: string): K | undefined
setOption(key: string, value: unknown): void
}
```
### ECS Mapping
The `WidgetHandle` facade maps to ECS components:
| WidgetHandle | ECS Component |
| ----------------------------- | ------------------------------- |
| `name`, `widgetType` | `WidgetComponentSchema` |
| `value` | `WidgetComponentValue` |
| `hidden`, `disabled`, `label` | `WidgetComponentDisplay` |
| `serialize` | `WidgetComponentSerialize` |
| type-specific options | `WidgetComponentSchema.options` |
The 5-component split is an implementation detail. Extensions see only Schema + Props.
## Consequences
### Positive
- Simple mental model: just two categories (Schema + Props)
- Aligns with Vue's component model (props, modelValue, one-way data flow)
- Clear rule: "presence is schema, values are props"
- ECS internals hidden behind facade
- `.value` accessor provides ergonomic access to the primary data
- Functions treated as values (JS-native thinking)
### Negative
- Existing code uses mixed patterns (`widget.hidden` vs `widget.options.hidden`) — migration needed
- The "presence vs value" distinction may be confusing initially
- `getOption`/`setOption` is less ergonomic than direct property access for common props
### Migration
For extensions currently using `widget.options.hidden = true`:
1. Phase A: Shim translates to internal mutation
2. Phase B: `setHidden()` dispatches ECS command (enables undo/redo)
3. Deprecation warnings guide to `widget.setHidden(true)` or `widget.setProp('hidden', true)`
## Notes
### Slack Discussion (2026-05-12)
Key insights from `#frontend-eng`:
- Austin: "Using min as an example. Under what circumstances would it change, or need to be externally observable?"
- Alex: "A lot of bugs come from 'changing the graph topology mutates values'"
- Christian: "The presence of min and max are immutable in the schema. Along with defaults. Their values would be props, which are only set by the systems"
- Christian: "Views of the data shouldn't directly mutate the props just like with Vue"
### Related Decisions
- D7: Widget shape and persistence model (superseded by this ADR for categorization)
- D13: ECS alignment audit (identified the dual `hidden` bug)
- D14: Decision log entry for this ADR
### Open Questions
1. How does this interact with Node Definition V3's `V3.CustomWidget`?
2. Schema merging for subgraph widgets with mixed constraints
3. Should connecting a second widget to a subgraph widget reset to default?

View File

@@ -1,111 +0,0 @@
# 11. Immutability Enforcement via Fresh Copies
Date: 2026-05-12
## Status
Accepted
## Context
The extension API exposes collection-returning methods like `widgets()`, `inputs()`, `outputs()`, and object-returning methods like `getProperties()`. These methods need immutability guarantees to prevent extensions from accidentally or intentionally mutating internal state.
### The Problem
Without runtime immutability enforcement:
- Extensions could push items into `widgets()` array, corrupting internal state
- Mutations to returned objects would silently affect internal data
- Debugging would be difficult — state corruption could surface far from the mutation site
- Internal framework code might inadvertently rely on returned arrays being stable
TypeScript's `readonly` modifier and JSDoc annotations provide compile-time protection, but:
- JavaScript consumers have no protection
- Type assertions can bypass readonly
- Agent-generated code may not respect type hints
### Options Considered
| Option | Pros | Cons |
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------- |
| **1. `Object.freeze()`** | Runtime immutability, throws on mutation | Performance overhead, nested objects need deep freeze |
| **2. Return fresh copy** | Simple, functional style, no mutation affects source | Slight memory overhead, multiple calls = multiple arrays |
| **3. Proxy wrapper** | Helpful error messages, can intercept specific operations | Complexity, performance overhead, harder to debug |
| **4. TypeScript only** | Zero runtime cost | No protection for JS consumers, can be bypassed |
| **5. Private fields** | True encapsulation | Blocks read access too, not suitable for APIs |
## Decision
**Return fresh copies** (Option 2) for all collection-returning and object-returning methods in the extension API.
### Implementation Pattern
```ts
// CORRECT: Return fresh copy
widgets(): readonly WidgetHandle[] {
const container = world.getComponent(nodeId, WidgetComponentContainer)
return (container?.widgetIds ?? []).map(createWidgetHandle)
// Each call creates new array — mutations don't affect internal state
}
getProperties(): Record<string, unknown> {
return { ...world.getComponent(nodeId, NodeTypeKey)?.properties }
// Shallow copy — mutations don't affect source
}
```
### Scope
Apply this pattern to:
- `NodeHandle.widgets()` — returns fresh `WidgetHandle[]`
- `NodeHandle.inputs()` — returns fresh `SlotInfo[]`
- `NodeHandle.outputs()` — returns fresh `SlotInfo[]`
- `NodeHandle.getProperties()` — returns fresh `Record<string, unknown>`
- `WidgetHandle` methods that return objects (if any)
- Any future collection/object-returning methods
### Internal Callers
Framework-internal code must also use mutation APIs rather than mutating returned collections:
```ts
// WRONG: Mutating returned array
const widgets = node.widgets()
widgets.push(newWidget) // No effect on node!
// CORRECT: Use mutation API
node.addWidget(type, name, value, options)
```
## Consequences
### Positive
- **True immutability**: Mutations to returned data never affect internal state
- **Predictable behavior**: Each call returns fresh data reflecting current state
- **Simple mental model**: "This is your copy, do what you want with it"
- **JavaScript-safe**: Works regardless of TypeScript types
### Negative
- **Memory overhead**: Multiple calls create multiple arrays (usually negligible)
- **No mutation detection**: Extensions silently get isolated copies, won't know their mutations are ignored
- **Fresh reference each call**: Cannot use `===` to detect changes (use deep comparison or events)
### Mitigations
- Document that returned collections are snapshots
- Use events (`valueChange`, `propertyChange`) to observe changes
- The memory overhead is negligible for typical widget/slot counts
## Notes
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 14 for the full discussion thread.
The alternative of `Object.freeze()` was rejected because:
- It requires deep freezing for nested objects
- Performance overhead for each call
- Fresh copies achieve the same goal more simply

View File

@@ -1,138 +0,0 @@
# 12. Pure Function Loader Pattern for Extension Registration
Date: 2026-05-12
## Status
Accepted
## Context
The v2 extension API needs a mechanism for extensions to register themselves with the runtime. Two broad approaches exist:
### Side-Effect Registration (Vue 2 Plugin Pattern)
```ts
// Extension self-registers at import time
import { app } from '@comfyorg/core'
app.use({
install(app) {
app.component('MyWidget', MyWidget)
app.directive('my-directive', myDirective)
}
})
```
Problems:
- **Import order matters**: If extension A depends on extension B being registered first, import order must be carefully managed
- **Hard to test**: Side effects at import time make mocking difficult; tests must manipulate module cache
- **Hard to tree-shake**: Bundlers can't eliminate unused extensions — the import executes
- **Timing coupling**: Registration and activation are conflated; can't collect extensions first, then activate later
### Pure Function + Loader Pattern
```ts
// Extension declares intent — no side effects
export default defineNode({
name: 'my-extension',
nodeTypes: ['MyNode'],
nodeCreated(handle) {
// ...
}
})
// App bootstrap activates all registered extensions
startExtensionSystem()
```
## Decision
**Adopt the pure function + loader pattern** for v2 extension registration.
### Implementation
```ts
// Extension Registry (data collection only)
const nodeExtensions: NodeExtensionOptions[] = []
export function defineNode(options: NodeExtensionOptions): void {
nodeExtensions.push(options)
}
// Loader (activation)
export function startExtensionSystem(): void {
const world = getWorld()
watch(
() => world.entitiesWith(NodeTypeKey),
(nodeEntityIds) => {
for (const id of nodeEntityIds) {
mountExtensionsForNode(id)
}
},
{ immediate: true }
)
}
```
### Key Properties
1. **Pure registration**: `defineNode()` has no side effects beyond pushing to an array. It doesn't touch the World, DOM, or any reactive state.
2. **Centralized activation**: `startExtensionSystem()` is called exactly once during app bootstrap. This single entry point controls when the extension system "goes live".
3. **Reactive mounting**: The loader watches the World for entity changes. Extensions are mounted/unmounted in response to ECS state, not imperative calls.
4. **Order independence**: Extensions can be defined in any order. The loader sorts by name (lexicographic, see D10b) for deterministic execution.
### Registration Flow
```
Extension files App bootstrap World
| | |
| defineNode({...}) | |
|--------------------->| |
| (push to array) | |
| | |
| | startExtensionSystem()
| |------------------>|
| | (watch for NodeType entities)
| | |
| | NodeType added |
| |<------------------|
| | |
| | mountExtensionsForNode(id)
| | (runs setup) |
```
## Consequences
### Positive
- **Testability**: Extensions are plain objects; tests can construct them without side effects. `_clearExtensionsForTesting()` resets state between tests.
- **Tree-shakeable**: Bundlers can eliminate unused extension files if their exports are never referenced.
- **Order independent**: No import order bugs — the loader handles activation order.
- **Lazy activation**: Registration is instant; activation only happens when `startExtensionSystem()` is called.
- **SSR friendly**: Pure functions don't execute browser-only code at import time.
### Negative
- **Manual bootstrap**: App must call `startExtensionSystem()` — forgetting it silently disables extensions.
- **Two-step mental model**: Developers must understand "register" vs "activate" phases.
### Mitigations
- App bootstrap is a well-defined location; the call is hard to miss.
- Clear documentation and starter templates include the bootstrap call.
- Dev-mode warnings if extensions are defined but the system never starts.
## Notes
This pattern aligns with modern framework conventions:
- **Vite plugins**: `vite.config.ts` collects plugins as an array; Vite activates them at build time.
- **Vue 3 Composition API**: `setup()` returns reactive state; the framework activates it.
- **React hooks**: Pure functions declare effects; React schedules them.
The key insight is separating **declaration** (what do I want?) from **execution** (make it happen). This separation enables testing, lazy loading, and predictable behavior.

View File

@@ -8,19 +8,16 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| ---------------------------------------------------------- | ------------------------------------------ | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0010](0010-deprecate-node-level-serialization-control.md) | Deprecate Node-Level Serialization Control | Accepted | 2026-05-12 |
| [0011](0011-immutability-via-fresh-copies.md) | Immutability Enforcement via Fresh Copies | Accepted | 2026-05-12 |
| [0012](0012-pure-function-loader-pattern.md) | Pure Function Loader Pattern | Accepted | 2026-05-12 |
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
## Creating a New ADR

View File

@@ -1,93 +0,0 @@
# Research: Canvas vs Client/Pixel Coordinate Usage
Date: 2026-05-12
## Question
How should the extension API handle coordinate systems? Should it expose canvas coordinates, screen/client coordinates, or both?
## Coordinate Systems in ComfyUI
### 1. Canvas Space (Logical Units)
Node positions and sizes are in canvas logical units:
- Independent of zoom/pan
- `[0, 0]` is the canvas origin
- Moving a node to `[100, 200]` places it at canvas position (100, 200) regardless of viewport state
### 2. Screen/Client Space (Pixels)
DOM elements use pixel coordinates relative to the viewport:
- Affected by zoom/pan/scroll
- `clientX`/`clientY` from mouse events
- `getBoundingClientRect()` returns pixel values
### 3. Widget Height (Pixels)
DOM widgets reserve height in pixels:
```ts
addDOMWidget({ name: 'preview', element: img, height: 200 }) // 200px
```
## Current Extension API
| Method | Coordinate System | Notes |
| -------------------------- | ----------------- | ----------------------------------------- |
| `getPosition()` | Canvas | Returns `[x, y]` in canvas units |
| `setPosition()` | Canvas | Accepts `[x, y]` in canvas units |
| `getSize()` | Canvas | Returns `[width, height]` in canvas units |
| `setSize()` | Canvas | Accepts `[width, height]` in canvas units |
| `addDOMWidget({ height })` | Pixels | Reserved height in pixels |
| `widget.setHeight(px)` | Pixels | Widget height in pixels |
## Analysis
### When Extensions Need Canvas Coordinates
1. **Node positioning**: Placing nodes relative to each other
2. **Layout algorithms**: Auto-arranging nodes in a pattern
3. **Collision detection**: Checking if nodes overlap
### When Extensions Need Screen Coordinates
1. **Custom overlays**: Drawing UI at a specific screen location
2. **Drag-and-drop from external sources**: Converting mouse position to canvas position
3. **Context menus**: Positioning menus near the cursor
### Current State
The extension API currently exposes:
- **Canvas coordinates** for node position/size — appropriate, as these are logical values
- **Pixel values** for DOM widget height — appropriate, as these are DOM measurements
**Missing**: No conversion helpers between canvas and screen coordinates.
## Recommendation
**The current approach is appropriate.** Extensions that manipulate node positions should work in canvas space. This is the natural abstraction — extensions shouldn't need to account for zoom/pan when laying out nodes.
### For Advanced Cases
Extensions needing coordinate conversion (e.g., custom overlays) should either:
1. **Use LiteGraph's existing transform utilities** (available on `app.canvas`)
2. **Access the transform state** via a future canvas API (not part of node/widget handles)
### Why Not Expose Conversion Helpers on NodeHandle?
- **Wrong abstraction level**: Coordinate conversion is a canvas concern, not a node concern
- **State dependency**: Conversion requires current zoom/pan state, which changes frequently
- **Rare use case**: Most extensions work entirely in canvas space
## Future Considerations
If multiple extensions need coordinate conversion, consider:
1. **Canvas API**: `canvas.screenToCanvas(point)` / `canvas.canvasToScreen(point)`
2. **Events with both coordinates**: `positionChanged` could include both canvas and screen positions
For now, no changes are needed — the current API serves the common cases well.

View File

@@ -1,93 +0,0 @@
# Research: DOM Widget Convergence with Base Widget
Date: 2026-05-12
## Question
Should DOM widgets be unified with base widgets, or kept as a separate concept?
## Current State
### Creation APIs
- `node.addWidget(type, name, value, options)` — creates a standard widget
- `node.addDOMWidget({ name, element, height })` — creates a DOM-backed widget
### Internal Implementation
Both use the same underlying `CreateWidget` command:
```ts
addWidget(type, name, defaultValue, options) {
return dispatch({ type: 'CreateWidget', widgetType: type, ... })
}
addDOMWidget(opts) {
return dispatch({ type: 'CreateWidget', widgetType: 'DOM', ... })
}
```
DOM widgets are just widgets with `widgetType: 'DOM'` and an element reference.
### Shared WidgetHandle Interface
Both widget types share the same `WidgetHandle` interface:
| Method | Standard Widget | DOM Widget |
| -------------------------------- | --------------- | ----------------------- |
| `entityId`, `name`, `widgetType` | ✓ | ✓ |
| `getValue()` / `setValue()` | ✓ (scalar) | ✓ (often unused) |
| `isHidden()` / `setHidden()` | ✓ | ✓ |
| `isDisabled()` / `setDisabled()` | ✓ | ✓ |
| `setHeight(px)` | no-op | ✓ (updates reservation) |
| `on('valueChange')` | ✓ | ✓ |
| `getOption()` / `setOption()` | ✓ | ✓ |
## Analysis
### Arguments FOR Full Convergence
1. **Single mental model**: Extensions learn one widget concept, not two.
2. **Consistent behavior**: All widgets appear in `node.widgets()`, serialize the same way.
3. **Simpler API surface**: Fewer methods to document and maintain.
### Arguments FOR Keeping Separate APIs
1. **Different ergonomics**: Standard widgets are data-driven (name, value, options); DOM widgets are element-driven (pass an HTMLElement).
2. **Type safety**: `addDOMWidget` can require `element: HTMLElement` at compile time; merging would make it optional with runtime checks.
3. **Clear intent**: Separate APIs signal different use cases.
## Recommendation
**Keep the current partial convergence.** The implementation is unified (`CreateWidget` command), but the creation APIs remain separate for ergonomic reasons.
### Rationale
1. **Creation differs, usage is unified.** Extensions create DOM widgets differently (need an element), but interact with them the same way (via `WidgetHandle`).
2. **Type safety is valuable.** `addDOMWidget({ element })` is clearer than `addWidget('DOM', name, null, { element })`.
3. **Already well-integrated.** DOM widgets appear in `node.widgets()`, get the same events, and use the same serialization infrastructure.
### What "Convergence" Means Here
The widgets are already converged at:
- **Entity level**: Same `WidgetEntityId` brand
- **Interface level**: Same `WidgetHandle` type
- **Command level**: Same `CreateWidget` command internally
The APIs are intentionally separate at:
- **Creation level**: `addWidget` vs `addDOMWidget`
This is the right split — unified where it matters (runtime behavior), separate where it improves DX (creation ergonomics).
## Future Considerations
If we add more widget creation patterns (e.g., `addCanvasWidget`, `addThreeJSWidget`), we might consider:
1. **Factory pattern**: `node.widgets.create('DOM', { element })` / `node.widgets.create('INT', { min, max })`
2. **Builder pattern**: `node.addWidget('DOM').withElement(el).withHeight(200).build()`
For now, two explicit methods (`addWidget`, `addDOMWidget`) serve the common cases well.

View File

@@ -1,112 +0,0 @@
# Research: Identity Encapsulation in the Extension API
Date: 2026-05-12
## Question
When do extensions need access to raw entity IDs (`NodeEntityId`, `WidgetEntityId`, `SlotEntityId`)? Should these be exposed or hidden?
## Current State
The v2 extension API exposes entity IDs as read-only properties:
```ts
interface NodeHandle {
readonly entityId: NodeEntityId
// ...
}
interface WidgetHandle {
readonly entityId: WidgetEntityId
// ...
}
interface SlotInfo {
readonly entityId: SlotEntityId
// ...
}
```
All IDs are **branded types** to prevent accidental mixing at compile time.
## Use Cases for Raw Entity IDs
### 1. Per-Instance State Mapping
Extensions maintaining external state per node:
```ts
const nodeCache = new Map<NodeEntityId, CachedData>()
defineNode({
name: 'my-cache-extension',
nodeCreated(handle) {
nodeCache.set(handle.entityId, computeExpensiveData())
onNodeRemoved(() => nodeCache.delete(handle.entityId))
}
})
```
### 2. Logging and Debugging
```ts
node.on('executed', (e) => {
console.log(`[${node.entityId}] Output:`, e.output)
})
```
### 3. Inter-Extension Communication
Extensions that need to coordinate across multiple nodes:
```ts
// Extension A stores data
globalState.set(nodeA.entityId, data)
// Extension B retrieves it
const data = globalState.get(nodeB.entityId)
```
### 4. External System Interop
Extensions integrating with analytics, debugging tools, or external services that need stable node identifiers.
## Analysis
### Arguments FOR Exposing Entity IDs
1. **Legitimate need exists** — The use cases above are real and common.
2. **Branded types prevent misuse** — Can't accidentally use `NodeEntityId` where `WidgetEntityId` is expected.
3. **Read-only access** — Extensions can't mutate the ID or corrupt internal state.
4. **Opaque value** — The format (`node:<graphUuid>:<localId>`) is an implementation detail; extensions should treat it as an opaque string.
### Arguments AGAINST Exposing Entity IDs
1. **Format coupling** — Extensions might parse the ID string and break if format changes.
2. **Internal detail leakage** — Knowing the ID scheme reveals ECS architecture.
3. **Future migration friction** — Changing ID representation requires careful deprecation.
### Mitigations
- **Document as opaque**: JSDoc clearly states IDs are opaque, not to be parsed.
- **Branded types**: TypeScript prevents misuse across entity categories.
- **Phase A format**: Current format includes graph UUID + local ID; this can evolve via semver.
## Recommendation
**Keep exposing entity IDs.** The use cases are legitimate, the branded types provide safety, and the read-only nature limits risk. Document that IDs are opaque strings — extensions should never parse or construct them.
### Guidelines for Extension Authors
1. **Use IDs only for keying** — Maps, Sets, logging, external system references.
2. **Never parse IDs** — The format is an implementation detail subject to change.
3. **Prefer handles over IDs** — When passing references between functions, use the handle object, not the raw ID.
4. **Clean up on removal** — Always use `onNodeRemoved()` to clean up Maps keyed by entityId.
## Future Considerations
If the ID format needs to change significantly, the branded types allow us to:
1. Introduce a new branded type (e.g., `NodeEntityIdV2`)
2. Deprecate the old ID with migration guidance
3. Keep both supported during a transition period

View File

@@ -1,121 +0,0 @@
# Research: Serialization Context Simplification
Date: 2026-05-12
## Question
Can the serialization context be simplified from 4 values to fewer?
Current contexts:
- `'workflow'` — saving workflow to disk
- `'prompt'` — queueing a run (API call)
- `'clone'` — copy/paste operation
- `'subgraph-promote'` — widget becoming subgraph IO
## Use Case Analysis
### Context: 'workflow'
**Purpose**: Full persistence of user's work.
**What extensions need**: Serialize everything the user configured.
**Example**: A widget storing user preferences needs to include all settings.
### Context: 'prompt'
**Purpose**: Sending data to the backend for execution.
**What extensions need**:
- Transform values (dynamic prompts → resolved text)
- Skip preview-only widgets
- Materialize async sources (webcam → frame data)
**Example**:
```ts
widget.on('beforeSerialize', async (e) => {
if (e.context === 'prompt') {
e.setSerializedValue(await captureFrame())
}
})
```
### Context: 'clone'
**Purpose**: Copy/paste should yield independent copy.
**What extensions need**: Reset instance-specific state while keeping user settings.
**Example**: A random seed widget might want a new seed on paste.
### Context: 'subgraph-promote'
**Purpose**: Widget becomes an input/output on a subgraph.
**What extensions need**: Convert internal representation to subgraph IO format.
**Example**: Internal state becomes an exposed parameter.
## Simplification Options
### Option A: Keep All 4 (Current State)
| Pro | Con |
| ---------------------------------------- | ----------------- |
| Each context has distinct semantics | 4 cases to handle |
| Type system enforces valid values | More complex API |
| Clear intent for each serialization path | |
### Option B: Collapse to 2 ('persist' | 'execute')
```ts
context: 'persist' | 'execute'
// 'persist' = workflow, clone, subgraph-promote
// 'execute' = prompt
```
| Pro | Con |
| ------------------------------------------ | ------------------------------- |
| Simpler mental model | Loses clone/promote distinction |
| Most extensions only care about this split | Can't reset seed on clone |
### Option C: Remove Context Entirely
Extensions always transform regardless of context. The framework handles differences.
| Pro | Con |
| ---------------------------- | ---------------------------------------------- |
| Simplest API | Loses control for edge cases |
| Framework handles all nuance | Some extensions need context-specific behavior |
## Recommendation
**Keep all 4 contexts.** The use cases are genuinely different:
1. **workflow vs prompt**: Very common distinction. Dynamic prompts only process on prompt; preview widgets skip prompt. This is the most important split.
2. **clone**: Less common, but needed for stateful widgets (random seeds, generated IDs, captured frames).
3. **subgraph-promote**: Specialized, but necessary for the subgraph feature to work correctly.
### Rationale
- Extensions that don't care can ignore the context.
- Extensions that do care have the information they need.
- The 4 values map to 4 distinct operations in the framework.
- Collapsing contexts would remove functionality with no real simplification gain.
### Mitigation for Complexity
- Document common patterns clearly
- Most extensions only need: `if (context === 'prompt')`
- Provide examples in JSDoc
## Note on Deprecation
The `NodeBeforeSerializeEvent` is deprecated (ADR-0010). The `WidgetBeforeSerializeEvent` remains supported and uses the same 4 contexts.
Since node-level serialization is being removed, this research applies to widget-level serialization only.

View File

@@ -1,148 +0,0 @@
# Widget State Categories
Date: 2026-05-12
## Overview
Widget state in the v2 extension API is organized into distinct categories, each with different characteristics for mutability, persistence, and event handling.
## Categories
### 1. Identity (Read-Only Invariants)
Set at construction, never change.
| Property | Type | Notes |
| ------------ | ---------------- | ------------------------------------ |
| `entityId` | `WidgetEntityId` | Branded, stable for widget lifetime |
| `name` | `string` | Widget name as registered |
| `widgetType` | `string` | e.g., `'INT'`, `'STRING'`, `'COMBO'` |
| `label` | `string` | Display label, defaults to `name` |
**Constraints:**
- No setters exist for these properties
- Extensions cannot modify identity after creation
- Attempting to change identity is a design error
### 2. Value (First-Class, Every Widget)
The primary user-edited data.
| Method | Notes |
| ------------------- | ----------------------------------- |
| `getValue()` | Returns current value |
| `setValue(v)` | Dispatches `SetWidgetValue` command |
| `on('valueChange')` | Fires on value mutation |
**Constraints:**
- Type varies by widget type (`number` for INT, `string` for STRING, etc.)
- Persisted to `widgets_values` in workflow JSON
- Included in API prompt by default (unless `setSerializeEnabled(false)`)
- Changes are undo-able via command dispatch
### 3. Properties (First-Class, Every Widget)
Common properties all widgets share.
| Property | Getter | Setter | Event |
| ----------- | ---------------------- | ------------------------ | ---------------- |
| `hidden` | `isHidden()` | `setHidden(b)` | `propertyChange` |
| `disabled` | `isDisabled()` | `setDisabled(b)` | `propertyChange` |
| `serialize` | `isSerializeEnabled()` | `setSerializeEnabled(b)` | `propertyChange` |
**Constraints:**
- Boolean values only
- `hidden` affects UI visibility, not serialization
- `disabled` makes widget read-only in UI
- `serialize` controls inclusion in workflow/prompt output
- Changes fire `propertyChange`, not `valueChange`
### 4. Options Bag (Type-Specific)
Per-instance overrides for type-specific configuration.
| Method | Notes |
| ----------------------- | ---------------------------------------------- |
| `getOption(key)` | Returns per-instance override or class default |
| `setOption(key, value)` | Persists to `widget_options` sidecar |
| `on('optionChange')` | Fires on option mutation |
**Common options by widget type:**
| Widget Type | Options |
| ----------- | ---------------------------------- |
| INT, FLOAT | `min`, `max`, `step`, `precision` |
| STRING | `multiline`, `placeholder`, `rows` |
| COMBO | `values` |
**Constraints:**
- Options are JSON-serializable values
- Persisted separately from `widgets_values` (additive, backward-compatible)
- Extensions can add custom options
- Option keys should be documented per widget type
### 5. DOM-Specific
Properties unique to DOM widgets.
| Method | Notes |
| --------------- | ------------------------------------------ |
| `setHeight(px)` | Updates reserved height, triggers relayout |
**Constraints:**
- Only meaningful for `addDOMWidget()` widgets
- No-op for non-DOM widgets
- Measured in pixels (screen space)
- No event fired; relayout is automatic
## Category Interaction Rules
### Event Separation
Each category has its own event:
| Category | Event |
| ---------- | ---------------- |
| Value | `valueChange` |
| Properties | `propertyChange` |
| Options | `optionChange` |
**Rule**: Events do not cross categories. Changing `hidden` does not fire `valueChange`.
### Serialization Behavior
| Category | Serialization |
| ---------- | ---------------------------------------------------------------- |
| Identity | Not serialized (derived from node type) |
| Value | `widgets_values` array |
| Properties | `hidden`/`disabled` not persisted; `serialize` affects inclusion |
| Options | `widget_options` sidecar object |
### Mutability Summary
| Category | Mutable | Undo-able | Fires Event |
| ---------- | ------- | --------- | ---------------- |
| Identity | ✗ | — | — |
| Value | ✓ | ✓ | `valueChange` |
| Properties | ✓ | ✓ | `propertyChange` |
| Options | ✓ | ✓ | `optionChange` |
| DOM Height | ✓ | ✗ | — |
## Agent Implementation Notes
Agents working with widget state should:
1. **Respect category boundaries**: Don't try to `setValue()` to change visibility; use `setHidden()`.
2. **Use appropriate events**: Listen to `propertyChange` for UI state, `valueChange` for data.
3. **Handle type-specific options carefully**: Check widget type before accessing type-specific options.
4. **Preserve identity invariants**: Never try to change `entityId`, `name`, `widgetType`, or `label`.
5. **Consider serialization context**: Options persist to a sidecar; values persist to the main array.

View File

@@ -82,7 +82,6 @@ export default defineConfig([
'components.d.ts',
'coverage/*',
'dist/*',
'packages/extension-api/api-snapshot/**',
'packages/registry-types/src/comfyRegistryTypes.ts',
'playwright-report/*',
'src/extensions/core/*',
@@ -104,10 +103,7 @@ export default defineConfig([
projectService: {
allowDefaultProject: [
'vite.electron.config.mts',
'vite.types.config.mts',
'packages/extension-api/scripts/build-docs.ts',
'packages/extension-api/vite.config.mts',
'vitest.extension-api.config.mts'
'vite.types.config.mts'
]
}
}

View File

@@ -1,12 +1,7 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
// I-TF (#12145): the test framework references symbols that foundation
// tags with @publicAPI (e.g. `_setDispatchImplForTesting`,
// `NodeExtensionOptions`). With tests present those tags become
// "redundant" hints. They are still correct on foundation alone, so
// we keep the tag definition and just downgrade hint→warning here.
treatConfigHintsAsErrors: false,
treatConfigHintsAsErrors: true,
workspaces: {
'.': {
entry: [
@@ -14,10 +9,6 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
// Public extension API surface — published package entry point.
// Per AGENTS.md, this barrel is the explicit exception to the
// no-barrel-files-in-src rule because it IS the package entry.
'src/extension-api/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
@@ -41,10 +32,6 @@ const config: KnipConfig = {
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']
},
'packages/extension-api': {
// Build output is committed for npm package visibility
ignore: ['build/**']
},
'apps/website': {
entry: ['src/scripts/**/*.ts']
}
@@ -73,48 +60,13 @@ const config: KnipConfig = {
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically
'tools/devtools/web/**',
// Deprecated stub re-exporting from `@/extension-api`. Will be removed
// once PKG2 (`@comfyorg/extension-api`) ships and downstream imports
// migrate to the package path.
'src/types/extensionV2.ts',
// D18 Phase 1 scaffolding — empty registries the loader will populate
// in Phase 2 once side-effect registration moves out of
// extension-api-service. See decisions/D18-pure-functions-loader-registration.md.
'src/services/registries/**',
// D18 Phase 1 — brand symbol + isBrandedExtension guard. Currently
// consumed only by the define* call sites inside extension-api-service;
// the type-guard and getBrandKind are exported for the Phase 2 loader.
'src/extension-api/brand.ts',
// Strangler-pattern v2 conversions of core extensions. Not yet wired
// into the bootstrap (registration lands in a follow-up PR alongside
// the v1→v2 cut-over). Tracked by I-EXT (#12144).
'src/extensions/core/noteNode.v2.ts',
'src/extensions/core/rerouteNode.v2.ts',
'src/extensions/core/slotDefaults.v2.ts',
// W6.P3.D — defineWidget+mount showcase port (D-widget-converge / A12).
'src/extensions/core/webcamCapture.v2.ts',
// W6.P4.D — canvas-units canary + escape-hatch annotation example
// (D-coord-space / A13).
'src/extensions/core/coordSpaceDemo.v2.ts',
// Reviewable .d.ts snapshots of the public surface — checked in for
// diff-friendliness in PR reviews. Not imported (the live build emits
// its own .d.ts under packages/extension-api/build/). Tracked under
// PKG3.D2 / PKG2 hand-written declaration-file rationale.
'packages/extension-api/api-snapshot/**',
// Test framework harness for v2 extension migration. Consumed by
// colocated *.v2.test.ts / *.migration.test.ts files; knip's vitest
// entry resolution does not yet see these as test infra. Tracked by
// I-TF (#12145).
'src/extension-api-v2/harness/**'
'tools/devtools/web/**'
],
vite: {
config: ['vite?(.*).config.mts']
},
vitest: {
// I-TF (#12145) adds vitest.extension-api.config.mts; project uses
// "type": "module" so vitest configs use the .mts extension.
config: ['vitest?(.*).config.ts', 'vitest?(.*).config.mts'],
config: ['vitest?(.*).config.ts'],
entry: [
'**/*.{bench,test,test-d,spec}.?(c|m)[jt]s?(x)',
'**/__mocks__/**/*.[jt]s?(x)'
@@ -127,15 +79,7 @@ const config: KnipConfig = {
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch',
'-knipIgnoreUsedByStackedPR',
// Public API surface consumed externally by extension authors and the
// TypeDoc docgen pipeline (PKG2). Mark exports with @publicAPI when they
// are part of `@comfyorg/extension-api` but not internally referenced.
'-publicAPI',
// Per D20, the three *EntityId brand re-exports in src/extension-api/{node,widget}.ts
// are demoted to @internal — they stay available for internal package modules
// but are removed from the public barrel and from TypeDoc output.
'-internal'
'-knipIgnoreUsedByStackedPR'
]
}

View File

@@ -31,12 +31,7 @@ export default {
}
function formatAndEslint(fileNames: string[]) {
// Exclude package build directories from linting
const filtered = fileNames.filter(
(f) => !f.includes('/packages/') || !f.includes('/build/')
)
if (filtered.length === 0) return []
const joinedPaths = toJoinedRelativePaths(filtered)
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,

View File

@@ -47,9 +47,6 @@
"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",

View File

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

View File

@@ -1,9 +0,0 @@
src/
scripts/
tsconfig*.json
typedoc.json
docs-build/
*.test.ts
*.spec.ts
__tests__/
node_modules/

View File

@@ -1,50 +0,0 @@
# @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

@@ -1,38 +0,0 @@
# API snapshot
Generated `.d.ts` files for the public surface of `@comfyorg/extension-api`.
Committed to git so reviewers can see exactly what extension authors will
consume — without having to build the package locally.
## Source of truth
These files are **generated** from the hand-written sources at
`src/extension-api/**` (in the foundation PR / ComfyUI_frontend root).
Do not edit them directly — they are regenerated by:
```bash
pnpm --filter @comfyorg/extension-api build
```
…which writes the same files to `packages/extension-api/build/extension-api/`.
Copy the result into this folder when the public surface changes.
## Why a snapshot, not the live `build/`?
`build/` is gitignored (it's a build artifact). Committing a separate
snapshot under a stable path gives reviewers a diffable record of any
public-API change without polluting git with the runtime `.js` and
declaration files emitted for every internal module.
## Files
| File | Source |
| ------------------ | --------------------------------------------------- |
| `index.d.ts` | `src/extension-api/index.ts` (barrel — entry point) |
| `events.d.ts` | `src/extension-api/events.ts` |
| `identifiers.d.ts` | `src/extension-api/identifiers.ts` |
| `lifecycle.d.ts` | `src/extension-api/lifecycle.ts` |
| `node.d.ts` | `src/extension-api/node.ts` |
| `shell.d.ts` | `src/extension-api/shell.ts` |
| `types.d.ts` | `src/extension-api/types.ts` |
| `widget.d.ts` | `src/extension-api/widget.ts` |

View File

@@ -1,39 +0,0 @@
/**
* Shared event infrastructure for the ComfyUI extension API.
*
* @stability stable
* @packageDocumentation
*/
/**
* A typed event handler function.
*
* @typeParam E - The event payload type.
* @stability stable
* @example
* ```ts
* const handler: Handler<WidgetValueChangeEvent<number>> = (e) => {
* console.log(e.oldValue, '->', e.newValue)
* }
* ```
*/
export type Handler<E> = (event: E) => void
/**
* A typed async-capable event handler. Only valid for events that explicitly
* support async handling (currently only `beforeSerialize`).
*
* @typeParam E - The event payload type.
* @stability stable
*/
export type AsyncHandler<E> = (event: E) => void | Promise<void>
/**
* Cleanup function returned by `on()` — call to remove the listener.
*
* @stability stable
* @example
* ```ts
* const off = node.on('executed', handler)
* // later:
* off()
* ```
*/
export type Unsubscribe = () => void

View File

@@ -1,23 +0,0 @@
/**
* Node identity helpers — re-exported from internal `nodeIdentification.ts`.
*
* `NodeLocatorId` and `NodeExecutionId` are the two stable node identity
* primitives in the public API. All extension-facing code that needs to
* reference a node across subgraph boundaries or execution runs should use
* these rather than raw LiteGraph integer node IDs.
*
* @stability stable
* @packageDocumentation
*/
export type {
NodeLocatorId,
NodeExecutionId
} from '../types/nodeIdentification'
export {
isNodeLocatorId,
isNodeExecutionId,
parseNodeLocatorId,
createNodeLocatorId,
parseNodeExecutionId,
createNodeExecutionId
} from '../types/nodeIdentification'

View File

@@ -1,105 +0,0 @@
/**
* @comfyorg/extension-api — Public Extension API for ComfyUI
*
* This barrel is the published package entry point. Every export here is
* part of the stable public contract that extension authors depend on.
*
* Import directly — no dependency on `window.app` at module evaluation time:
*
* ```ts
* import { defineNodeExtension, defineExtension } from '@comfyorg/extension-api'
* ```
*
* ## API surface overview
*
* | Export | Purpose |
* |--------|---------|
* | `defineNodeExtension` | Register a node-scoped extension (the primary entry point) |
* | `defineExtension` | Register an app-scoped extension (init, setup, shell UI) |
* | `onNodeMounted`, `onNodeRemoved` | Implicit-context lifecycle hooks (call inside nodeCreated) |
* | `NodeHandle` | Controlled access to node state and events |
* | `WidgetHandle` | Controlled access to widget state and events |
* | `WidgetBeforeQueueEvent` | Pre-queue validation event — call `reject(msg)` to cancel |
* | `SlotInfo` | Read-only slot snapshot |
* | `NodeEntityId`, `WidgetEntityId`, `SlotEntityId` | Branded entity IDs |
* | Shell UI types | `SidebarTabExtension`, `BottomPanelExtension`, `CommandManager`, etc. |
* | Identity helpers | `NodeLocatorId`, `NodeExecutionId`, parsers, type guards |
*
* ## API style (D3.3)
*
* The public API is **event + getter/setter**, not signals. Vue reactivity is
* the internal engine; extension authors never import from Vue or use
* `ref`/`computed`/`effect` directly. State is read via methods (`getValue()`,
* `getPosition()`), mutated via command-dispatch methods (`setValue()`,
* `setPosition()`), and observed via typed event subscriptions (`on('executed', fn)`).
* Read-only invariants (set at construction, never change) are exposed as
* accessors (`get entityId`, `get type`).
*
* ## Barrel-file rule exception
*
* ComfyUI_frontend AGENTS.md rule #19 normally forbids barrel files in `/src`.
* This barrel is the **published package entry point** — not an internal
* re-export — and is the explicit exception documented in AGENTS.md.
*
* @packageDocumentation
*/
export type {
ExtensionOptions,
NodeExtensionOptions,
WidgetExtensionOptions
} from './types'
export {
defineExtension,
defineNodeExtension,
defineWidgetExtension
} from '../services/extension-api-service'
export { onNodeMounted, onNodeRemoved } from './lifecycle'
export type {
NodeHandle,
NodeEntityId,
SlotEntityId,
SlotInfo,
SlotDirection,
NodeMode,
Point,
Size,
DOMWidgetOptions,
NodeExecutedEvent,
NodeConnectedEvent,
NodeDisconnectedEvent,
NodePositionChangedEvent,
NodeSizeChangedEvent,
NodeModeChangedEvent,
NodeBeforeSerializeEvent
} from './node'
export type {
WidgetHandle,
WidgetEntityId,
WidgetValue,
WidgetOptions,
WidgetValueChangeEvent,
WidgetOptionChangeEvent,
WidgetPropertyChangeEvent,
WidgetBeforeSerializeEvent,
WidgetBeforeQueueEvent
} from './widget'
export type { Handler, AsyncHandler, Unsubscribe } from './events'
export type {
SidebarTabExtension,
BottomPanelExtension,
VueExtension,
CustomExtension,
ToastMessageOptions,
ToastManager,
ExtensionManager,
CommandManager
} from './shell'
export type { NodeLocatorId, NodeExecutionId } from './identifiers'
export {
isNodeLocatorId,
isNodeExecutionId,
parseNodeLocatorId,
createNodeLocatorId,
parseNodeExecutionId,
createNodeExecutionId
} from './identifiers'

View File

@@ -1,154 +0,0 @@
import {
NodeExtensionOptions,
ExtensionOptions,
WidgetExtensionOptions
} from './types'
/**
* Extension lifecycle — `defineExtension`, `defineNodeExtension`, and
* the implicit-context lifecycle hooks (`onNodeMounted`, `onNodeRemoved`).
*
* Design decisions (D10):
* - D10a: `currentExtension` global, Vue-style. Hook factories read the slot
* implicitly. Lifecycle hooks must be called synchronously inside `setup()`.
* - D10b: Hook firing order = registration order with lexicographic tie-break
* on extension name.
* - D10c: `setup()` is synchronous. `async setup` throws in dev, emits
* console.error in prod.
* - D10d: The object returned by `setup()` is wrapped with `proxyRefs()` so
* callers read `entity.extensionState['my-ext'].count` without `.value`.
*
* Entry-point design (D6 Part 1): module-level import only. Extensions do NOT
* depend on `window.app` being initialized at registration time.
*
* @stability stable
* @packageDocumentation
*/
/**
* @publicAPI
* Back-compat re-exports of the extension option contracts. Prefer importing
* from `@comfyorg/extension-api` (or `@/extension-api`); the
* `@/extension-api/lifecycle` path is preserved for downstream code that
* imported these types from the original module.
*/
export type {
NodeExtensionOptions,
ExtensionOptions,
WidgetExtensionOptions
} from './types'
/**
* Register a node extension. The runtime calls `nodeCreated` or
* `loadedGraphNode` once per node entity matching `nodeTypes`.
*
* This is the primary entry point for extensions that interact with nodes and
* widgets. Import directly from `@comfyorg/extension-api` — no dependency on
* `window.app` at module evaluation time (D6 Part 1).
*
* Hook firing order across multiple extensions on the same entity follows
* extension registration order with a lexicographic tie-break on `name` (D10b).
*
* @stability stable
* @publicAPI
* @example
* ```ts
* import { defineNodeExtension } from '@comfyorg/extension-api'
*
* export default defineNodeExtension({
* name: 'Comfy.PreviewAny',
* nodeTypes: ['PreviewAny'],
*
* nodeCreated(node) {
* const preview = node.addWidget('STRING', 'preview', '', {
* multiline: true, readonly: true, serialize: false
* })
* node.on('executed', (e) => {
* preview.setValue(String(e.output['text'] ?? ''))
* })
* }
* })
* ```
*/
export declare function defineNodeExtension(
options: NodeExtensionOptions
): NodeExtensionOptions
/**
* Register an extension for app-wide lifecycle and shell UI contributions.
*
* Use `defineNodeExtension` for node/widget interactions. Use this for
* `init`, `setup`, sidebar tabs, commands, and other app-level concerns.
*
* @stability stable
* @publicAPI
* @example
* ```ts
* import { defineExtension } from '@comfyorg/extension-api'
*
* export default defineExtension({
* name: 'my-org.my-extension',
* setup() {
* console.log('Extension ready')
* }
* })
* ```
*/
export declare function defineExtension(
options: ExtensionOptions
): ExtensionOptions
/**
* Register a custom widget type. Called once at module load time to declare
* a new widget kind.
*
* @stability experimental
* @publicAPI
* @example
* ```ts
* import { defineWidgetExtension } from '@comfyorg/extension-api'
*
* export default defineWidgetExtension({
* name: 'my-org.color-picker',
* type: 'COLOR_PICKER'
* })
* ```
*/
export declare function defineWidgetExtension(
options: WidgetExtensionOptions
): WidgetExtensionOptions
export {
/**
* Register a callback to fire when the node entity is fully mounted to the
* graph (the reactive mount watcher has run, the scope is active, and
* `setup()` has completed).
*
* Must be called synchronously inside `nodeCreated` or `loadedGraphNode`.
*
* @stability experimental
* @example
* ```ts
* nodeCreated(node) {
* onNodeMounted(() => {
* // Safe to access DOM widgets, canvas, etc.
* })
* }
* ```
*/
onNodeMounted,
/**
* Register a callback to fire when the node entity is removed from the graph
* (NOT on subgraph promotion, which is a DOM-move, not an unmount).
*
* Replaces `nodeType.prototype.onRemoved` patching (S2.N4 — 7+ repos,
* 4.89 blast radius).
*
* Must be called synchronously inside `nodeCreated` or `loadedGraphNode`.
*
* @stability experimental
* @example
* ```ts
* nodeCreated(node) {
* onNodeRemoved(() => {
* cleanup()
* })
* }
* ```
*/
onNodeRemoved
} from '../services/extension-api-service'

View File

@@ -1,472 +0,0 @@
import { AsyncHandler, Handler, Unsubscribe } from './events'
import { WidgetHandle, WidgetOptions } from './widget'
import { NodeEntityId } from '../world/entityIds'
export type { NodeEntityId }
/**
* A 2D point as `[x, y]`.
*
* @stability stable
*/
export type Point = [x: number, y: number]
/**
* A 2D size as `[width, height]`.
*
* @stability stable
*/
export type Size = [width: number, height: number]
/**
* LiteGraph node execution mode.
*
* Numeric values match `LGraphEventMode` in the LiteGraph runtime.
*
* - `0` — `ALWAYS`: execute every run (default).
* - `1` — `ON_EVENT`: legacy slot for the dead trigger/action subsystem;
* has no behavioural effect in the current scheduler. Reserved for ABI
* compatibility — do not use in new extensions.
* - `2` — `NEVER`: muted; node is skipped during execution.
* - `3` — `ON_TRIGGER`: legacy slot for the dead trigger/action subsystem;
* gated behind `LiteGraph.do_add_triggers_slots` (always `false`). Reserved
* for ABI compatibility — do not use in new extensions.
* - `4` — `BYPASS`: passthrough; inputs are forwarded to outputs without
* running the node.
*
* Practical extension code should use `0` (always) or `2` (never/muted) or
* `4` (bypass). Slots `1` and `3` are documented for completeness but their
* runtime semantics are pending the AUDIT-LG trigger-subsystem cleanup.
*
* @stability stable
*/
export type NodeMode = 0 | 1 | 2 | 3 | 4
/**
* Direction of a slot on a node.
*
* @stability stable
*/
export type SlotDirection = 'input' | 'output'
/**
* Read-only snapshot of a single slot (input or output) on a node.
*
* @stability stable
*/
export interface SlotInfo {
/** Branded entity ID for this slot. */
readonly entityId: SlotEntityId
/** Slot name as declared in `INPUT_TYPES` or `addInput`/`addOutput`. */
readonly name: string
/** Slot type string (e.g. `'IMAGE'`, `'LATENT'`, `'*'`). */
readonly type: string
/** Whether this is an input or output slot. */
readonly direction: SlotDirection
/** The node this slot belongs to. */
readonly nodeEntityId: NodeEntityId
}
/**
* Branded entity ID for slots. Prevents mixing slot IDs with node/widget IDs.
*
* @stability stable
*/
export type SlotEntityId = number & {
readonly __brand: 'SlotEntityId'
}
/**
* Payload for `node.on('executed', handler)`.
*
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern.
*
* @stability stable
* @example
* ```ts
* node.on('executed', (e) => {
* const text = e.output['text'] as string[]
* previewWidget.setValue(text.join('\n'))
* })
* ```
*/
export interface NodeExecutedEvent {
/** The backend execution output for this node. Shape varies by node type. */
readonly output: Record<string, unknown>
}
/**
* Payload for `node.on('connected', handler)`.
*
* Replaces `nodeType.prototype.onConnectInput` / `onConnectOutput` and
* `nodeType.prototype.onConnectionsChange` patching.
*
* @stability stable
*/
export interface NodeConnectedEvent {
/** The local slot that was connected. */
readonly slot: SlotInfo
/** The remote slot on the other node. */
readonly remote: SlotInfo
}
/**
* Payload for `node.on('disconnected', handler)`.
*
* @stability stable
*/
export interface NodeDisconnectedEvent {
/** The local slot that was disconnected. */
readonly slot: SlotInfo
}
/**
* Payload for `node.on('positionChanged', handler)`.
*
* @stability stable
*/
export interface NodePositionChangedEvent {
/** The new position. */
readonly pos: Point
}
/**
* Payload for `node.on('sizeChanged', handler)`.
*
* @stability stable
*/
export interface NodeSizeChangedEvent {
/** The new size. */
readonly size: Size
}
/**
* Payload for `node.on('modeChanged', handler)`.
*
* @stability stable
*/
export interface NodeModeChangedEvent {
/** The new execution mode. */
readonly mode: NodeMode
}
/**
* Payload for `node.on('beforeSerialize', handler)`.
*
* The node-level equivalent of `WidgetBeforeSerializeEvent`. Replaces both
* `node.onSerialize` and `nodeType.prototype.serialize` patching patterns
* (v1 S2.N6, S2.N15 touch-points).
*
* Mutate `event.data` in place to append extra fields (replaces `onSerialize`).
* Call `event.replace(fn)` to wrap the entire serialized object (replaces
* `prototype.serialize = function(){ const r = orig.call(this); … }`).
*
* @stability experimental
* @example
* ```ts
* // Append a field
* node.on('beforeSerialize', (e) => {
* e.data['my_extra'] = computeExtra()
* })
*
* // Wrap the serialized object
* node.on('beforeSerialize', (e) => {
* e.replace((orig) => ({ ...orig, wrapped: true }))
* })
* ```
*/
export interface NodeBeforeSerializeEvent {
/** Which serialization path triggered this. */
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
/**
* The mutable serialized node object. Mutate in place to append fields.
* Type intentionally loose — the exact shape is `ISerialisedNode`.
*/
readonly data: Record<string, unknown>
/**
* Replace the serialized object by providing a transform function.
* `fn` receives the current `data` and should return the replacement.
* Calling this multiple times chains: each call's `fn` receives the
* previous call's output.
*/
replace(fn: (orig: Record<string, unknown>) => Record<string, unknown>): void
}
/**
* Options for `NodeHandle.addDOMWidget()`.
*
* @stability experimental
*/
export interface DOMWidgetOptions {
/** Unique widget name within this node. */
name: string
/** The DOM element to embed in the node widget area. */
element: HTMLElement
/** Reserved height in pixels. Defaults to `element.offsetHeight` at mount time. */
height?: number
}
/**
* Controlled surface for node access. Reads query the ECS World; writes
* dispatch commands. Events are Vue-reactive watches on World components.
*
* @stability stable
* @example
* ```ts
* import { defineNodeExtension } from '@comfyorg/extension-api'
*
* export default defineNodeExtension({
* name: 'my-size-enforcer',
* nodeTypes: ['MyCustomNode'],
*
* nodeCreated(node) {
* const [w, h] = node.getSize()
* node.setSize([Math.max(w, 300), Math.max(h, 200)])
*
* node.on('executed', (e) => {
* console.log('output:', e.output)
* })
* }
* })
* ```
*/
export interface NodeHandle {
/**
* Stable entity ID for this node. Branded to prevent mixing with
* `WidgetEntityId` at compile time.
*
* @stability stable
*/
readonly entityId: NodeEntityId
/**
* The LiteGraph node type string (e.g. `'KSampler'`).
* Read-only invariant: set at construction, never changes.
*
* @stability stable
*/
readonly type: string
/**
* The ComfyUI backend class name (e.g. `'KSampler'`).
* Equal to `type` for most nodes; differs for reroute/virtual nodes.
* Read-only invariant.
*
* @stability stable
*/
readonly comfyClass: string
/**
* Returns the node's current canvas position as `[x, y]`.
*
* @stability stable
*/
getPosition(): Point
/**
* Moves the node to a new canvas position. Dispatches a `MoveNode` command.
*
* @stability stable
*/
setPosition(pos: Point): void
/**
* Returns the node's current size as `[width, height]`.
*
* @stability stable
*/
getSize(): Size
/**
* Resizes the node. Dispatches a `ResizeNode` command.
*
* @stability stable
*/
setSize(size: Size): void
/**
* Returns the node's display title. Defaults to the node type string.
*
* @stability stable
*/
getTitle(): string
/**
* Sets the node's display title. Dispatches a `SetNodeVisual` command.
*
* @stability stable
*/
setTitle(title: string): void
/**
* Returns `true` if the node is currently selected on the canvas.
*
* @stability stable
*/
isSelected(): boolean
/**
* Returns the node's current execution mode.
*
* @stability stable
*/
getMode(): NodeMode
/**
* Sets the node's execution mode. Dispatches a `SetNodeMode` command.
*
* @stability stable
*/
setMode(mode: NodeMode): void
/**
* Returns a per-node-instance property by key.
*
* In v2, prefer routing persistent state through widget values or
* `beforeSerialize` events. `node.properties` is kept as a migration shim
* for v1 extensions that used it for per-instance widget config (e.g. min/max).
*
* @stability stable
*/
getProperty<T = unknown>(key: string): T | undefined
/**
* Returns a copy of all per-node-instance properties.
*
* @stability stable
*/
getProperties(): Record<string, unknown>
/**
* Sets a per-node-instance property. Dispatches a `SetNodeProperty` command.
*
* In v2, prefer `widget.setOption(key, value)` for widget-scoped per-instance
* config (it persists to the `widget_options` sidecar in the workflow JSON).
*
* @stability stable
*/
setProperty(key: string, value: unknown): void
/**
* Returns a `WidgetHandle` for the named widget, or `undefined` if no such
* widget exists on this node.
*
* @stability stable
* @example
* ```ts
* const steps = node.widget('steps')
* if (steps) steps.setValue(20)
* ```
*/
widget(name: string): WidgetHandle | undefined
/**
* Returns all widgets on this node as `WidgetHandle` instances.
*
* @stability stable
*/
widgets(): readonly WidgetHandle[]
/**
* Adds a new widget to this node.
*
* @param type - Widget type string (e.g. `'INT'`, `'STRING'`, `'COMBO'`).
* @param name - Unique widget name on this node.
* @param defaultValue - Initial value.
* @param options - Optional type-specific options.
* @returns The new `WidgetHandle`.
* @stability stable
*/
addWidget(
type: string,
name: string,
defaultValue: unknown,
options?: Partial<WidgetOptions>
): WidgetHandle
/**
* Adds a DOM-backed widget to this node.
*
* Replaces the v1 `node.addDOMWidget(name, type, element, opts)` pattern.
* The runtime automatically:
* - Reserves node height for the element (via auto-computeSize integration).
* - Removes the element from the DOM when the node is removed.
* - Includes the widget in `NodeHandle.widgets()`.
*
* Use `WidgetHandle.setHeight(px)` to resize the reservation after initial mount.
*
* @param opts.name - Unique widget name on this node.
* @param opts.element - The DOM element to embed.
* @param opts.height - Initial reserved height in pixels. Defaults to `element.offsetHeight`.
* @returns A `WidgetHandle` for the registered DOM widget.
* @stability experimental
*/
addDOMWidget(opts: DOMWidgetOptions): WidgetHandle
/**
* Returns all input slots on this node.
*
* @stability stable
*/
inputs(): readonly SlotInfo[]
/**
* Returns all output slots on this node.
*
* @stability stable
*/
outputs(): readonly SlotInfo[]
/**
* Subscribe to node removal (graph deletion, not subgraph promotion).
*
* Replaces the v1 `nodeType.prototype.onRemoved` patching pattern.
* Does NOT fire on subgraph promotion — the node's entity ID is preserved
* across promotion (see D9 Phase A notes and D12).
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(event: 'removed', handler: Handler<void>): Unsubscribe
/**
* Subscribe to backend execution completion for this node.
*
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern (the
* most widely used anti-pattern per R4-P3; 5+ confirmed repos).
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(event: 'executed', handler: Handler<NodeExecutedEvent>): Unsubscribe
/**
* Subscribe to workflow hydration (node loaded from a saved workflow).
*
* Replaces the v1 `nodeType.prototype.onConfigure` / `loadedGraphNode`
* patterns. Fires after all widget values are restored from the workflow JSON.
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(event: 'configured', handler: Handler<void>): Unsubscribe
/**
* Subscribe to slot connection events.
*
* Replaces `nodeType.prototype.onConnectInput`, `onConnectOutput`, and
* `onConnectionsChange` patching patterns (R4-P4: six distinct signatures
* in the wild — this single typed event resolves the confusion).
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(event: 'connected', handler: Handler<NodeConnectedEvent>): Unsubscribe
/**
* Subscribe to slot disconnection events.
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(
event: 'disconnected',
handler: Handler<NodeDisconnectedEvent>
): Unsubscribe
/**
* Subscribe to canvas position changes.
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(
event: 'positionChanged',
handler: Handler<NodePositionChangedEvent>
): Unsubscribe
/**
* Subscribe to node size changes.
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(event: 'sizeChanged', handler: Handler<NodeSizeChangedEvent>): Unsubscribe
/**
* Subscribe to execution mode changes.
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(event: 'modeChanged', handler: Handler<NodeModeChangedEvent>): Unsubscribe
/**
* Subscribe to node serialization. Async-capable.
*
* Replaces `nodeType.prototype.onSerialize` and `nodeType.prototype.serialize`
* patching patterns. Collapses four v1 serialization surfaces to one (D7 Part 4).
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'beforeSerialize',
handler: AsyncHandler<NodeBeforeSerializeEvent>
): Unsubscribe
}

View File

@@ -1,21 +0,0 @@
/**
* Shell UI extension types — sidebar tabs, bottom panels, commands, toasts.
*
* Re-exported from `src/types/extensionTypes.ts` with no shape changes.
* The original module remains the source of truth; this barrel makes the
* shell types available from the single `@comfyorg/extension-api` package
* entry point.
*
* @stability stable
* @packageDocumentation
*/
export type {
SidebarTabExtension,
BottomPanelExtension,
VueExtension,
CustomExtension,
ToastMessageOptions,
ToastManager,
ExtensionManager,
CommandManager
} from '../types/extensionTypes'

View File

@@ -1,171 +0,0 @@
import { NodeHandle } from './node'
import { WidgetHandle } from './widget'
/**
* Options for `defineNodeExtension`. Describes an extension that reacts to
* node lifecycle events.
*
* @stability stable
* @example
* ```ts
* import { defineNodeExtension } from '@comfyorg/extension-api'
*
* export default defineNodeExtension({
* name: 'my-org.my-extension',
* nodeTypes: ['KSampler'],
*
* nodeCreated(node) {
* node.on('executed', (e) => console.log('done', e.output))
* }
* })
* ```
*/
export interface NodeExtensionOptions {
/**
* Globally unique extension name. Used for scope registry keying, hook
* ordering (D10b lexicographic tie-break), and debug messages.
*
* Convention: `'org.extension-name'` or `'Comfy.ExtensionName'`.
*
* @stability stable
*/
name: string
/**
* Filter to specific `comfyClass` names. When omitted, the extension
* receives `nodeCreated` / `loadedGraphNode` for every node type.
*
* Replaces the v1 `beforeRegisterNodeDef` filtering pattern (DEP1).
*
* @stability stable
* @example
* ```ts
* nodeTypes: ['KSampler', 'KSamplerAdvanced']
* ```
*/
nodeTypes?: string[]
/**
* Called once per node instance when the node is first created (typed in,
* pasted from clipboard, duplicated, or loaded without an existing workflow).
*
* - Runs inside a Vue `EffectScope`. All `watch` / `computed` / `onNodeMounted`
* calls made here are captured and disposed automatically on node removal.
* - Must be synchronous (D10c). Kick off async work inside the body; use
* `loading: ref(true)` for async-dependent state.
* - Called only once per entity ID lifetime. Copy/paste creates a fresh entity
* and fires `nodeCreated` again on the new entity (D12 reset-to-fresh).
*
* @stability stable
*/
nodeCreated?(node: NodeHandle): void
/**
* Called once per node instance when the node is restored from a saved
* workflow. Widget values are already populated when this fires.
*
* Same rules as `nodeCreated`. Exactly one of `nodeCreated` or
* `loadedGraphNode` fires per node entity, never both.
*
* Replaces the v1 `loadedGraphNode` hook (which had near-zero real usage per
* R4-P11) and `nodeType.prototype.onConfigure` patching.
*
* @stability stable
*/
loadedGraphNode?(node: NodeHandle): void
}
/**
* Options for the global `defineExtension` entry point. Covers extension-wide
* lifecycle and shell UI contributions.
*
* @stability stable
* @example
* ```ts
* import { defineExtension } from '@comfyorg/extension-api'
*
* export default defineExtension({
* name: 'my-org.my-extension',
* async setup() {
* // App is ready; register commands, sidebar tabs, etc.
* }
* })
* ```
*/
export interface ExtensionOptions {
/**
* Globally unique extension name. Matches the format of
* `NodeExtensionOptions.name`.
*
* @stability stable
*/
name: string
/**
* Declared API version of this extension. Used by the telemetry system to
* track v1 → v2 adoption (D6 Phase D gate: "<5% v1 usage before dropping
* the v1 bridge"). Set to `'2'` for extensions written against this API.
*
* Optional in Phase A (no runtime enforcement). The runtime reads this field
* via `getExtensionVersionReport()` to produce adoption metrics.
*
* @stability stable
* @example
* ```ts
* defineExtension({ name: 'my-ext', apiVersion: '2', setup() { … } })
* ```
*/
apiVersion?: string
/**
* Runs once during app initialization (after the app is mounted but before
* the first workflow is loaded). Equivalent to the v1 `ComfyExtension.init`.
*
* @stability stable
*/
init?(): void | Promise<void>
/**
* Runs once after the app and all core extensions are initialized. Equivalent
* to the v1 `ComfyExtension.setup`. Safe to call shell UI registration APIs
* (`ExtensionManager`, `CommandManager`) here.
*
* @stability stable
*/
setup?(): void | Promise<void>
}
/**
* Options for `defineWidgetExtension`. Describes an extension that provides a
* custom widget type with its own DOM rendering.
*
* @stability experimental
* @example
* ```ts
* import { defineWidgetExtension } from '@comfyorg/extension-api'
*
* export default defineWidgetExtension({
* name: 'my-org.color-picker',
* type: 'COLOR_PICKER',
*
* widgetCreated(widget, node) {
* return {
* // mount color picker DOM
* render(container) {},
* // cleanup
* destroy() {}
* }
* }
* })
* ```
*/
export interface WidgetExtensionOptions {
/** Globally unique extension name. */
name: string
/** Widget type string this extension provides (e.g. `'COLOR_PICKER'`). */
type: string
/**
* Called once per widget instance. Return a `{ render, destroy }` pair for
* custom DOM rendering, or `void` for non-visual widgets.
*
* @stability experimental
*/
widgetCreated?(
widget: WidgetHandle,
parentNode: NodeHandle | null
): {
render(container: HTMLElement): void
destroy?(): void
} | void
}

View File

@@ -1,472 +0,0 @@
import { AsyncHandler, Handler, Unsubscribe } from './events'
import { WidgetEntityId } from '../world/entityIds'
export type { WidgetEntityId }
/**
* The union of all legal widget scalar values. Complex widgets (DOM, canvas)
* may return their own serializable shapes.
*
* @stability stable
*/
export type WidgetValue = string | number | boolean | null
/**
* Payload for `widget.on('valueChange', handler)`.
*
* Replaces the v1 `widget.callback` pattern.
*
* @typeParam T - The widget's value type.
* @stability stable
* @example
* ```ts
* widget.on('valueChange', (e) => {
* console.log('changed from', e.oldValue, 'to', e.newValue)
* })
* ```
*/
export interface WidgetValueChangeEvent<T = WidgetValue> {
/** Value before the change. */
readonly oldValue: T
/** Value after the change. */
readonly newValue: T
}
/**
* Payload for `widget.on('optionChange', handler)`.
*
* Fires when a type-specific option is mutated via `setOption(key, value)`.
* The exact set of observable option keys is type-dependent (e.g. `min`,
* `max`, `step` for numeric widgets; `multiline` for strings).
*
* The data model for "options" vs "first-class fields" is defined in D7.
* This event covers the options-bag tier (type-specific, not every-widget).
*
* @stability experimental — full semantics deferred to D7
* @example
* ```ts
* widget.on('optionChange', (e) => {
* if (e.key === 'min') clampValue(e.newValue as number)
* })
* ```
*/
export interface WidgetOptionChangeEvent {
/** The option key that changed (e.g. `'min'`, `'max'`, `'multiline'`). */
readonly key: string
/** Value before the change. */
readonly oldValue: unknown
/** Value after the change. */
readonly newValue: unknown
}
/**
* Payload for `widget.on('propertyChange', handler)`.
*
* Fires when a first-class every-widget property is mutated — specifically
* `hidden`, `disabled`, and `serialize` (the non-value first-class fields
* defined in D7 Part 1). Does NOT fire for `value` changes (use `valueChange`)
* or for options-bag mutations (use `optionChange`).
*
* @stability experimental — property enumeration finalised in D7
* @example
* ```ts
* widget.on('propertyChange', (e) => {
* if (e.property === 'hidden') updateLayout(e.newValue as boolean)
* })
* ```
*/
export interface WidgetPropertyChangeEvent {
/**
* Which first-class property changed.
* - `'hidden'` — visibility toggled via `setHidden()`
* - `'disabled'` — enabled/disabled via `setDisabled()`
* - `'serialize'` — serialization opt-in/out via `setSerializeEnabled()`
*/
readonly property: 'hidden' | 'disabled' | 'serialize'
/** Value before the change. */
readonly oldValue: boolean
/** Value after the change. */
readonly newValue: boolean
}
/**
* Payload for `widget.on('beforeSerialize', handler)`.
*
* This is the **only async-allowed event** in v1 (per D10c / D5 Part 3).
* Replaces `widget.serializeValue`, `widget.options.serialize = false`, and
* the v1 `widget.serializeValue = (workflowNode, widgetIndex) => ...` pattern.
*
* Call `event.setSerializedValue(v)` to override what is written to
* `widgets_values[i]` and the API prompt. Call `event.skip()` to exclude this
* widget from the prompt entirely. Do not call either to pass through the
* widget's current `getValue()` unchanged.
*
* @typeParam T - The widget's value type.
* @stability stable
* @example
* ```ts
* // Dynamic prompts: replace value at serialize time
* widget.on('beforeSerialize', (e) => {
* if (e.context === 'prompt') {
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
* }
* })
*
* // Preview widget: exclude from prompt
* widget.on('beforeSerialize', (e) => {
* if (e.context === 'prompt') e.skip()
* })
*
* // Async: webcam capture — materialize frame before prompt builds
* widget.on('beforeSerialize', async (e) => {
* if (e.context === 'prompt') {
* const frame = await captureFrame()
* e.setSerializedValue(frame)
* }
* })
* ```
*/
export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
/**
* Which serialization path triggered this handler.
*
* - `'workflow'` — user is saving the workflow to disk (full round-trip).
* - `'prompt'` — user is queueing a run (only prompt-relevant data sent to backend).
* - `'clone'` — a copy/paste is happening; the framework already populated the
* cloned entity's widget value from the source. Override only if the clone should
* differ from the source. (See D12 for scope copy semantics.)
* - `'subgraph-promote'` — the widget is being promoted to a subgraph IO slot.
*/
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
/**
* The widget's current value at the time of serialization (before any override).
* Equivalent to calling `widget.getValue()`.
*/
readonly value: T
/**
* Override the serialized value. The provided value is written to
* `widgets_values[i]` (and to the API prompt for `context='prompt'`).
* Calling this multiple times keeps the last call's value.
*
* @param v - The value to serialize. Must be JSON-serializable.
*/
setSerializedValue(v: unknown): void
/**
* Exclude this widget from the API prompt entirely.
* Only meaningful for `context='prompt'`; no-ops on other contexts.
* Replaces `widget.options.serialize = false` and `() => undefined` patterns.
*/
skip(): void
}
/**
* Payload for `widget.on('beforeQueue', handler)`.
*
* Fires when the user triggers a prompt queue (before `graphToPrompt` runs).
* Call `event.reject(message)` to cancel the queue attempt with a user-visible
* error. Do not call `reject` to allow the queue to proceed.
*
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
* for per-widget validation (e.g. required field empty, value out of range).
* For cross-node/graph-wide rejection, see the app-level `beforePrompt` event
* (I-UWF.4 — not yet in the API).
*
* @stability experimental
* @example
* ```ts
* // Reject if a required field is empty
* widget.on('beforeQueue', (e) => {
* if (!widget.getValue()) {
* e.reject('Prompt text is required before queueing.')
* }
* })
*
* // Reject with a dynamic message
* widget.on('beforeQueue', (e) => {
* const val = widget.getValue<number>()
* const min = widget.getOption<number>('min') ?? 0
* if (val < min) {
* e.reject(`Value ${val} is below the minimum of ${min}.`)
* }
* })
* ```
*/
export interface WidgetBeforeQueueEvent {
/**
* Reject the queue attempt, showing `message` to the user.
* Once any handler calls `reject`, the queue is cancelled — subsequent
* handlers still run but their `reject` calls are no-ops.
*
* @param message - Human-readable reason shown in the UI toast.
*/
reject(message: string): void
}
/**
* Controlled surface for widget access. Backed by ECS `WidgetValue` and
* `WidgetIdentity` components in the World. Reads query components directly;
* writes dispatch commands (undo-able, serializable, validatable).
*
* All views (node, properties panel, promoted copy) share the same backing
* `WidgetEntityId`, so mutations from any source trigger `valueChange`.
*
* @typeParam T - The type of `getValue()` / `setValue()`. Defaults to `WidgetValue`.
* @stability stable
* @example
* ```ts
* import { defineNodeExtension } from '@comfyorg/extension-api'
*
* export default defineNodeExtension({
* name: 'my-extension',
* nodeCreated(node) {
* const steps = node.widget('steps')
* if (!steps) return
*
* steps.on('valueChange', (e) => console.log('steps =', e.newValue))
* steps.setOption('min', 1)
* steps.setOption('max', 150)
* }
* })
* ```
*/
export interface WidgetHandle<T = WidgetValue> {
/**
* Stable entity identifier for this widget. Branded to prevent mixing with
* `NodeEntityId` at compile time.
*
* @stability stable
*/
readonly entityId: WidgetEntityId
/**
* The widget's name as registered in `INPUT_TYPES` or `addWidget`. Stable
* for the lifetime of the node; never changes after creation.
*
* @stability stable
*/
readonly name: string
/**
* The widget's type string (e.g. `'INT'`, `'STRING'`, `'COMBO'`,
* `'MARKDOWN'`). Read-only invariant set at creation.
*
* @stability stable
*/
readonly widgetType: string
/**
* Returns the widget's current user-edited value.
*
* @typeParam T - Narrows the return type when you know the widget type.
* @stability stable
* @example
* ```ts
* const steps = node.widget('steps')!.getValue<number>()
* ```
*/
getValue(): T
/**
* Sets the widget's value. Dispatches a `SetWidgetValue` command (undo-able).
* Triggers `valueChange` handlers on all views.
*
* @stability stable
*/
setValue(value: T): void
/**
* Returns `true` if the widget is currently hidden from the node UI.
*
* @stability stable
*/
isHidden(): boolean
/**
* Show or hide the widget. Dispatches a `SetWidgetHidden` command.
*
* @stability stable
* @example
* ```ts
* toggle.on('valueChange', (e) => {
* detail.setHidden(!e.newValue)
* })
* ```
*/
setHidden(hidden: boolean): void
/**
* Returns `true` if the widget is disabled (read-only in the UI).
*
* @stability stable
*/
isDisabled(): boolean
/**
* Enable or disable the widget.
*
* @stability stable
*/
setDisabled(disabled: boolean): void
/**
* The widget's display label shown to the user. Defaults to the widget name.
* Read-only invariant per D6 Part 3 (set at creation, never changes after).
*
* To override at construction, pass `label` to `addWidget()` options.
*
* @stability stable
*/
readonly label: string
/**
* Updates the reserved height for this DOM widget and triggers a node relayout.
*
* Only meaningful for widgets registered via `NodeHandle.addDOMWidget()`.
* For non-DOM widgets this is a no-op.
*
* Replaces the v1 pattern of re-assigning `node.computeSize` to return a new
* height whenever the embedded element resizes.
*
* @param px - New reserved height in pixels.
* @stability experimental
*/
setHeight(px: number): void
/**
* Returns `true` if this widget is included in workflow and prompt
* serialization. Defaults to `true` for all widget types.
*
* @stability stable
*/
isSerializeEnabled(): boolean
/**
* Enable or disable serialization for this widget. When disabled, the widget
* is excluded from both `widgets_values` in the workflow JSON and the API
* prompt payload. Equivalent to the v1 `widget.options.serialize = false`
* pattern.
*
* @stability stable
*/
setSerializeEnabled(enabled: boolean): void
/**
* Returns the per-instance override for `key`, or the class-default value
* from `INPUT_TYPES` if no override has been set, or `undefined` if the key
* is unknown for this widget type.
*
* Type-specific option names: `min`, `max`, `step` (INT/FLOAT); `multiline`,
* `dynamicPrompts` (STRING); `image_folder`, `upload_to` (upload widgets).
*
* @stability stable
* @example
* ```ts
* const min = widget.getOption<number>('min') ?? 0
* ```
*/
getOption<K = unknown>(key: string): K | undefined
/**
* Set a per-instance option override. Persisted as a `widget_options` sidecar
* in the workflow JSON (additive, backward-compatible). Does not change the
* backend prompt schema unless the extension explicitly opts in via
* `beforeSerialize`.
*
* @stability stable
* @example
* ```ts
* // Primitive Int/Float per-instance config (replaces node.properties anti-pattern)
* widget.setOption('min', 0)
* widget.setOption('max', 100)
* widget.setOption('step', 1)
* ```
*/
setOption(key: string, value: unknown): void
/**
* Subscribe to the widget's value changes.
*
* Replaces the v1 `widget.callback` pattern.
* Fires synchronously after the value is committed (per D10c).
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(
event: 'valueChange',
handler: Handler<WidgetValueChangeEvent<T>>
): Unsubscribe
/**
* Subscribe to type-specific option mutations (`setOption(key, value)`).
*
* Fires for options-bag changes (e.g. `min`, `max`, `step`, `multiline`).
* Does NOT fire for value changes or first-class field changes.
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'optionChange',
handler: Handler<WidgetOptionChangeEvent>
): Unsubscribe
/**
* Subscribe to first-class property mutations (`setHidden`, `setDisabled`,
* `setSerializeEnabled`).
*
* Does NOT fire for `setValue` (use `valueChange`) or options-bag mutations
* (use `optionChange`).
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'propertyChange',
handler: Handler<WidgetPropertyChangeEvent>
): Unsubscribe
/**
* Subscribe to widget serialization. The only async-allowed event (D10c / D5).
*
* Replaces `widget.serializeValue = fn` and the v1 `widget.options.serialize`
* flag. The handler may be sync or async; async handlers are awaited before
* the serialization payload is sent.
*
* @returns A cleanup function to remove the listener.
* @stability stable
*/
on(
event: 'beforeSerialize',
handler: AsyncHandler<WidgetBeforeSerializeEvent<T>>
): Unsubscribe
/**
* Subscribe to pre-queue validation. Fires before `graphToPrompt` runs.
*
* Call `event.reject(message)` to cancel the queue with a user-visible error.
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
* for per-widget validation use cases.
*
* Handlers are sync-only — use for validation logic only, not I/O.
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'beforeQueue',
handler: Handler<WidgetBeforeQueueEvent>
): Unsubscribe
}
/**
* Options passed to `node.addWidget()` when creating a new widget.
*
* Type-specific keys (e.g. `min`, `max`, `step` for numeric widgets;
* `multiline`, `dynamicPrompts` for strings) are passed through as-is.
*
* @stability stable
*/
export interface WidgetOptions {
/** If `true`, the widget is hidden from the node UI on creation. */
hidden?: boolean
/** If `true`, the widget is rendered read-only (no user editing). */
readonly?: boolean
/** If `false`, this widget is excluded from workflow/prompt serialization. */
serialize?: boolean
/** Display label override. Defaults to the widget `name`. */
label?: string
/** Toggle label shown when value is `true` (BOOLEAN widgets). */
labelOn?: string
/** Toggle label shown when value is `false` (BOOLEAN widgets). */
labelOff?: string
/** Multiline text input (STRING widgets). */
multiline?: boolean
/**
* When `true`, the widget value is processed for dynamic prompt syntax
* at serialize time. (STRING widgets with `dynamicPrompts: true`.)
*/
dynamicPrompts?: boolean
/** Min value for numeric widgets (INT, FLOAT). */
min?: number
/** Max value for numeric widgets. */
max?: number
/** Step size for numeric widgets. */
step?: number
/** Default value at construction time. */
default?: unknown
/** Any additional type-specific option. */
[key: string]: unknown
}

View File

@@ -1,2 +0,0 @@
index.js
index.js.map

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +0,0 @@
{
"name": "@comfyorg/extension-api",
"version": "0.1.0",
"description": "Official TypeScript extension API for ComfyUI custom nodes",
"files": [
"build",
"README.md"
],
"type": "module",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"import": "./build/index.js",
"default": "./build/index.js"
}
},
"scripts": {
"typecheck": "vite build --logLevel warn",
"build": "vite build",
"docs:build": "tsx scripts/build-docs.ts",
"docs:watch": "tsx scripts/build-docs.ts --watch"
},
"devDependencies": {
"tsx": "catalog:",
"typedoc": "0.28.19",
"typedoc-plugin-markdown": "^4.6.3",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-dts": "catalog:"
},
"peerDependencies": {
"vue": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:api"
]
}
}

View File

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

View File

@@ -1,17 +0,0 @@
/**
* @comfyorg/extension-api — Public Extension API for ComfyUI
*
* This is the package entry point compiled to `build/index.js` + `build/index.d.ts`.
* It is a single re-export of the canonical surface defined in
* `src/extension-api/index.ts` in the main app — that file is the one source
* of truth for what is part of the stable, semver-versioned public contract.
*
* Do NOT add exports here. Add them to `src/extension-api/index.ts` and they
* will flow through this barrel automatically.
*
* The tsconfig.json `paths` alias `@/*` → `../../src/*` resolves the import
* below at both typecheck and build time.
*
* @packageDocumentation
*/
export * from '@/extension-api/index'

View File

@@ -1,41 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "ES2023.Array", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"declaration": true,
"declarationMap": false,
"noEmit": false,
"outDir": "./build",
"paths": {
"@/*": ["../../src/*"],
"@/utils/formatUtil": [
"../../packages/shared-frontend-utils/src/formatUtil.ts"
],
"@/utils/networkUtil": [
"../../packages/shared-frontend-utils/src/networkUtil.ts"
]
}
},
"include": [
"../../src/**/*.ts",
"../../src/types/litegraph-augmentation.d.ts",
"../../global.d.ts"
],
"exclude": [
"../../src/**/*.test.ts",
"../../src/**/*.spec.ts",
"../../src/**/*.vue",
"**/*.test.ts",
"**/*.spec.ts",
"scripts/**"
]
}

View File

@@ -1,19 +0,0 @@
{
"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

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"noEmit": true,
"declaration": false,
"declarationMap": false
}
}

View File

@@ -1,45 +0,0 @@
{
"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

@@ -1,74 +0,0 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
const here = fileURLToPath(new URL('.', import.meta.url))
const repoRoot = resolve(here, '..', '..')
const repoSrc = resolve(repoRoot, 'src')
const surfaceRoot = resolve(repoSrc, 'extension-api')
/**
* Library build for `@comfyorg/extension-api`.
*
* Per ADR D17 (PKG2 build strategy), the package is built from the canonical
* surface defined in the main app at `src/extension-api/index.ts`. Vite
* resolves the `@/*` aliases against the main app's `src/` directory and
* emits a single bundled `index.js` plus a single bundled `index.d.ts`.
*
* The package barrel at `packages/extension-api/src/index.ts` is the
* Vite entry point and re-exports `@/extension-api/index` — preserving
* "the barrel is the source of truth in main app `src/extension-api/`"
* intent in `packages/extension-api/AGENTS.md`.
*
* Vue is externalized as a peer dependency (per D6.1 Phase A — extension
* authors share the host app's Vue runtime).
*/
export default defineConfig({
resolve: {
alias: {
'@/utils/formatUtil': resolve(
repoRoot,
'packages/shared-frontend-utils/src/formatUtil.ts'
),
'@/utils/networkUtil': resolve(
repoRoot,
'packages/shared-frontend-utils/src/networkUtil.ts'
),
'@': repoSrc
}
},
build: {
outDir: resolve(here, 'build'),
emptyOutDir: true,
sourcemap: true,
target: 'es2022',
minify: false,
lib: {
// Build directly from the canonical surface in the main app — the
// package's own `src/index.ts` exists only as a documented entry
// point that re-exports the same surface, but we point Vite at the
// canonical file so dts paths line up cleanly with the JS bundle.
entry: resolve(surfaceRoot, 'index.ts'),
formats: ['es'],
fileName: () => 'index.js'
},
rollupOptions: {
// Vue is provided by the host app at runtime.
external: ['vue', /^@vue\//]
}
},
plugins: [
dts({
// Bundle all types into a single index.d.ts. This ensures the package
// is self-contained and doesn't reference paths outside build/.
rollupTypes: true,
outDir: resolve(here, 'build'),
tsconfigPath: resolve(here, 'tsconfig.build.json'),
logLevel: 'warn',
// Only include the extension-api surface, not the entire app
include: [resolve(surfaceRoot, '**/*.ts')]
})
]
})

420
pnpm-lock.yaml generated
View File

@@ -650,7 +650,7 @@ importers:
version: 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
'@nx/vite':
specifier: 'catalog:'
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
'@pinia/testing':
specifier: 'catalog:'
version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))
@@ -662,7 +662,7 @@ importers:
version: 4.6.0
'@storybook/addon-docs':
specifier: 'catalog:'
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/addon-mcp':
specifier: 'catalog:'
version: 0.1.6(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
@@ -671,10 +671,10 @@ importers:
version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
'@storybook/vue3-vite':
specifier: 'catalog:'
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@testing-library/jest-dom':
specifier: 'catalog:'
version: 6.9.1
@@ -704,7 +704,7 @@ importers:
version: 0.170.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.0.16(vitest@4.0.16)
@@ -842,19 +842,19 @@ importers:
version: 11.1.0
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-dts:
specifier: 'catalog:'
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-html:
specifier: 'catalog:'
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.2.6
@@ -912,10 +912,10 @@ importers:
devDependencies:
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
dotenv:
specifier: 'catalog:'
version: 16.6.1
@@ -927,13 +927,13 @@ importers:
version: 30.0.0(@babel/parser@7.29.0)(vue@3.5.13(typescript@5.9.3))
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-html:
specifier: 'catalog:'
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.5(typescript@5.9.3)
@@ -982,16 +982,16 @@ importers:
version: 0.9.8(prettier@3.7.4)(typescript@5.9.3)
'@astrojs/vue':
specifier: 'catalog:'
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.9.0)
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
'@playwright/test':
specifier: 'catalog:'
version: 1.58.1
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
astro:
specifier: 'catalog:'
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0)
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
tailwindcss:
specifier: 'catalog:'
version: 4.2.0
@@ -1003,7 +1003,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/design-system:
dependencies:
@@ -1030,31 +1030,6 @@ importers:
specifier: 'catalog:'
version: 5.9.3
packages/extension-api:
dependencies:
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
devDependencies:
tsx:
specifier: 'catalog:'
version: 4.19.4
typedoc:
specifier: 0.28.19
version: 0.28.19(typescript@5.9.3)
typedoc-plugin-markdown:
specifier: ^4.6.3
version: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
typescript:
specifier: 'catalog:'
version: 5.9.3
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-dts:
specifier: 'catalog:'
version: 4.5.4(@types/node@25.0.3)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
packages/ingest-types:
dependencies:
zod:
@@ -2456,9 +2431,6 @@ packages:
'@formkit/auto-animate@0.9.0':
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
'@gerrit0/mini-shiki@3.23.0':
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
'@grpc/grpc-js@1.9.15':
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
engines: {node: ^8.13.0 || >=10.10.0}
@@ -5481,10 +5453,6 @@ packages:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -7656,9 +7624,6 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lunr@2.3.9:
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -7899,10 +7864,6 @@ packages:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
@@ -9382,19 +9343,6 @@ packages:
typed-binary@4.3.2:
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
typedoc-plugin-markdown@4.11.0:
resolution: {integrity: sha512-2iunh2ALyfyh204OF7h2u0kuQ84xB3jFZtFyUr01nThJkLvR8oGGSSDlyt2gyO4kXhvUxDcVbO0y43+qX+wFbw==}
engines: {node: '>= 18'}
peerDependencies:
typedoc: 0.28.x
typedoc@0.28.19:
resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==}
engines: {node: '>= 18', pnpm: '>= 10'}
hasBin: true
peerDependencies:
typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x
typegpu@0.8.2:
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
engines: {node: '>=12.20.0'}
@@ -10238,11 +10186,6 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -10524,14 +10467,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.9.0)':
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
dependencies:
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/compiler-sfc': 3.5.28
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- '@nuxt/kit'
@@ -11920,14 +11863,6 @@ snapshots:
'@formkit/auto-animate@0.9.0': {}
'@gerrit0/mini-shiki@3.23.0':
dependencies:
'@shikijs/engine-oniguruma': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@grpc/grpc-js@1.9.15':
dependencies:
'@grpc/proto-loader': 0.7.13
@@ -12316,14 +12251,6 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor-model@7.33.1(@types/node@25.0.3)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@microsoft/tsdoc-config': 0.18.0
'@rushstack/node-core-library': 5.20.1(@types/node@25.0.3)
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor@7.57.2(@types/node@24.10.4)':
dependencies:
'@microsoft/api-extractor-model': 7.33.1(@types/node@24.10.4)
@@ -12343,25 +12270,6 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor@7.57.2(@types/node@25.0.3)':
dependencies:
'@microsoft/api-extractor-model': 7.33.1(@types/node@25.0.3)
'@microsoft/tsdoc': 0.16.0
'@microsoft/tsdoc-config': 0.18.0
'@rushstack/node-core-library': 5.20.1(@types/node@25.0.3)
'@rushstack/rig-package': 0.7.1
'@rushstack/terminal': 0.22.1(@types/node@25.0.3)
'@rushstack/ts-command-line': 5.3.1(@types/node@25.0.3)
diff: 8.0.3
lodash: 4.17.23
minimatch: 10.2.1
resolve: 1.22.11
semver: 7.5.4
source-map: 0.6.1
typescript: 5.8.2
transitivePeerDependencies:
- '@types/node'
'@microsoft/tsdoc-config@0.18.0':
dependencies:
'@microsoft/tsdoc': 0.16.0
@@ -12587,11 +12495,11 @@ snapshots:
- typescript
- verdaccio
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)':
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
dependencies:
'@nx/devkit': 22.6.1(nx@22.6.1)
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
'@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3)
ajv: 8.18.0
enquirer: 2.3.6
@@ -12599,8 +12507,8 @@ snapshots:
semver: 7.7.4
tsconfig-paths: 4.2.0
tslib: 2.8.1
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -12611,7 +12519,7 @@ snapshots:
- typescript
- verdaccio
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)':
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
dependencies:
'@nx/devkit': 22.6.1(nx@22.6.1)
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
@@ -12619,8 +12527,8 @@ snapshots:
semver: 7.7.4
tslib: 2.8.1
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -13234,27 +13142,10 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.4
'@rushstack/node-core-library@5.20.1(@types/node@25.0.3)':
dependencies:
ajv: 8.13.0
ajv-draft-04: 1.0.0(ajv@8.13.0)
ajv-formats: 3.0.1(ajv@8.13.0)
fs-extra: 11.3.2
import-lazy: 4.0.0
jju: 1.4.0
resolve: 1.22.11
semver: 7.5.4
optionalDependencies:
'@types/node': 25.0.3
'@rushstack/problem-matcher@0.2.1(@types/node@24.10.4)':
optionalDependencies:
'@types/node': 24.10.4
'@rushstack/problem-matcher@0.2.1(@types/node@25.0.3)':
optionalDependencies:
'@types/node': 25.0.3
'@rushstack/rig-package@0.7.1':
dependencies:
resolve: 1.22.11
@@ -13268,14 +13159,6 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.4
'@rushstack/terminal@0.22.1(@types/node@25.0.3)':
dependencies:
'@rushstack/node-core-library': 5.20.1(@types/node@25.0.3)
'@rushstack/problem-matcher': 0.2.1(@types/node@25.0.3)
supports-color: 8.1.1
optionalDependencies:
'@types/node': 25.0.3
'@rushstack/ts-command-line@5.3.1(@types/node@24.10.4)':
dependencies:
'@rushstack/terminal': 0.22.1(@types/node@24.10.4)
@@ -13285,15 +13168,6 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@rushstack/ts-command-line@5.3.1(@types/node@25.0.3)':
dependencies:
'@rushstack/terminal': 0.22.1(@types/node@25.0.3)
'@types/argparse': 1.0.38
argparse: 1.0.10
string-argv: 0.3.2
transitivePeerDependencies:
- '@types/node'
'@sec-ant/readable-stream@0.4.1': {}
'@sentry-internal/browser-utils@10.32.1':
@@ -13443,10 +13317,10 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.4)
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@storybook/react-dom-shim': 10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
react: 19.2.4
@@ -13472,25 +13346,25 @@ snapshots:
- '@tmcp/auth'
- typescript
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ts-dedent: 2.2.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- esbuild
- rollup
- webpack
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.3
rollup: 4.53.5
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@storybook/global@5.0.0': {}
@@ -13515,14 +13389,14 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/vue3': 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
magic-string: 0.30.21
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
typescript: 5.9.3
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue-component-meta: 2.2.12(typescript@5.9.3)
vue-docgen-api: 4.79.2(vue@3.5.13(typescript@5.9.3))
transitivePeerDependencies:
@@ -13604,19 +13478,19 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.0
'@tailwindcss/oxide-win32-x64-msvc': 4.2.0
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@tailwindcss/node': 4.2.0
'@tailwindcss/oxide': 4.2.0
tailwindcss: 4.2.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@tailwindcss/node': 4.2.0
'@tailwindcss/oxide': 4.2.0
tailwindcss: 4.2.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@tanstack/virtual-core@3.13.12': {}
@@ -14209,32 +14083,32 @@ snapshots:
vue: 3.5.13(typescript@5.9.3)
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-rc.9
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue: 3.5.13(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue: 3.5.13(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue: 3.5.13(typescript@5.9.3)
'@vitest/coverage-v8@4.0.16(vitest@4.0.16)':
@@ -14250,7 +14124,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -14271,21 +14145,21 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -14321,7 +14195,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -14496,38 +14370,38 @@ snapshots:
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-shared': 7.7.9
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- vite
@@ -14935,7 +14809,7 @@ snapshots:
astral-regex@2.0.0: {}
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0):
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
@@ -14992,8 +14866,8 @@ snapshots:
unist-util-visit: 5.1.0
unstorage: 1.17.4
vfile: 6.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
@@ -15186,10 +15060,6 @@ snapshots:
dependencies:
balanced-match: 4.0.3
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.3
braces@3.0.3:
dependencies:
fill-range: 7.1.1
@@ -17593,8 +17463,6 @@ snapshots:
lru-cache@8.0.5: {}
lunr@2.3.9: {}
lz-string@1.5.0: {}
lz-utils@2.1.0: {}
@@ -18030,10 +17898,6 @@ snapshots:
dependencies:
brace-expansion: 5.0.2
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
@@ -19963,19 +19827,6 @@ snapshots:
typed-binary@4.3.2: {}
typedoc-plugin-markdown@4.11.0(typedoc@0.28.19(typescript@5.9.3)):
dependencies:
typedoc: 0.28.19(typescript@5.9.3)
typedoc@0.28.19(typescript@5.9.3):
dependencies:
'@gerrit0/mini-shiki': 3.23.0
lunr: 2.3.9
markdown-it: 14.1.1
minimatch: 10.2.5
typescript: 5.9.3
yaml: 2.9.0
typegpu@0.8.2:
dependencies:
tinyest: 0.1.2
@@ -20260,27 +20111,27 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
birpc: 2.9.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
birpc: 2.9.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
'@microsoft/api-extractor': 7.57.2(@types/node@24.10.4)
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
@@ -20293,32 +20144,13 @@ snapshots:
magic-string: 0.30.21
typescript: 5.9.3
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- '@types/node'
- rollup
- supports-color
vite-plugin-dts@4.5.4(@types/node@25.0.3)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@microsoft/api-extractor': 7.57.2(@types/node@25.0.3)
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
'@volar/typescript': 2.4.28
'@vue/language-core': 2.2.0(typescript@5.9.3)
compare-versions: 6.1.1
debug: 4.4.3
kolorist: 1.8.0
local-pkg: 1.1.2
magic-string: 0.30.21
typescript: 5.9.3
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- rollup
- supports-color
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
'@rollup/pluginutils': 4.2.1
colorette: 2.0.20
@@ -20332,9 +20164,9 @@ snapshots:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
'@rollup/pluginutils': 4.2.1
colorette: 2.0.20
@@ -20348,9 +20180,9 @@ snapshots:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
@@ -20361,12 +20193,12 @@ snapshots:
perfect-debounce: 1.0.0
picocolors: 1.1.1
sirv: 3.0.2
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- rollup
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
ansis: 4.2.0
debug: 4.4.3
@@ -20376,12 +20208,12 @@ snapshots:
perfect-debounce: 2.0.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
ansis: 4.2.0
debug: 4.4.3
@@ -20391,56 +20223,56 @@ snapshots:
perfect-debounce: 2.0.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)):
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-kit': 7.7.9
'@vue/devtools-shared': 7.7.9
execa: 9.6.1
sirv: 3.0.2
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
transitivePeerDependencies:
- '@nuxt/kit'
- rollup
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)):
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)):
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
@@ -20451,11 +20283,11 @@ snapshots:
'@vue/compiler-dom': 3.5.28
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
@@ -20466,11 +20298,11 @@ snapshots:
'@vue/compiler-dom': 3.5.28
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0
@@ -20485,9 +20317,9 @@ snapshots:
jiti: 2.6.1
terser: 5.39.2
tsx: 4.19.4
yaml: 2.9.0
yaml: 2.8.2
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0
@@ -20502,16 +20334,16 @@ snapshots:
jiti: 2.6.1
terser: 5.39.2
tsx: 4.19.4
yaml: 2.9.0
yaml: 2.8.2
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20528,7 +20360,7 @@ snapshots:
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -20550,10 +20382,10 @@ snapshots:
- tsx
- yaml
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20570,7 +20402,7 @@ snapshots:
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -20980,7 +20812,7 @@ snapshots:
yaml-eslint-parser@1.3.0:
dependencies:
eslint-visitor-keys: 3.4.3
yaml: 2.9.0
yaml: 2.8.2
yaml-language-server@1.20.0:
dependencies:
@@ -21002,8 +20834,6 @@ snapshots:
yaml@2.8.2: {}
yaml@2.9.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:

View File

@@ -1,915 +0,0 @@
meta:
schema_version: 1
generated_from:
- database.yaml
- rollup.yaml
- star-cache.yaml
generated_by: scripts/build-behavior-categories.py (I-TF.1)
source_pattern_count: 62
category_count: 37
usage_weight_formula: sum_over_members(blast_radius * occurrences)
exemplar_ranking: repo_stars desc, then pattern blast_radius desc; distinct (repo, pattern_id)
notes:
- Categories cluster by intent, not by surface_family. S2 is split into creation / teardown / hydration / interaction /
drawing / connection / serialization / properties.
- S1 hooks are merged with their prototype-patching equivalents where intent matches (BC.20 node-type reg, BC.22 menus,
BC.03 hydration).
- S8.P1 isVirtualNode is a registration-time flag, so it lives in BC.20 alongside the node-type registration hooks.
- S10.D3 setSize and S15.OS1 dynamic outputs join S10.D1 in BC.09 dynamic-slot-mutation since they all describe runtime
topology mutation.
- S14.ID1 NodeLocatorId joins S11.G2 graph enumeration in BC.29 because both are about cross-scope node identity/resolution.
- S11.G1/G3/G4 (version, batching, setDirtyCanvas) collapse into BC.30 graph change-tracking — the v2 reactivity story replaces
all three.
- BC.21 (S1.H2 getCustomWidgets) has only 2 evidence rows in database.yaml; this is the 'small family — 2 + 1 minor variant'
acceptance carve-out. The two exemplars are kept as-is, no synthetic third row.
- BC.31 and BC.32 added 2026-05-08 from Notion API usage research (notion-api-research-evidence.yaml staging).
S16 is a new surface family (DOM injection) not previously tracked. S16.VUE1 grouped with BC.32 (embedded runtimes).
S3.C2 (ContextMenu replacement) added to BC.06 member list.
- Notion source also upgrades occurrence signal on BC.01/BC.02/BC.04/BC.06/BC.07/BC.09/BC.26/BC.29/BC.30 — reflected
in staging file; usage_weight values below are NOT yet updated (need re-run of rollup-blast-radius.py after merge).
- BC.33 (cross-ext DOM widget obs), BC.34 (settings dialog), BC.35 (pre-queue validation) added 2026-05-08 from Notion COM-3668.
- BC.36 (PrimeVue widget API surface) added 2026-05-08 from Notion Widget Component APIs page; was erroneously numbered BC.33 — corrected.
- BC.37 (VueNode bridge timing) added 2026-05-08 from Notion Frontend Architecture page (3536d73d). Captures the
nodeCreated→VueNode-not-yet-mounted hazard and the waitForLoad3d deferral pattern as a concrete test fixture.
categories:
- category_id: BC.01
name: 'Node lifecycle: creation'
intent: Hooks fired when a node is constructed or attached to the graph (per-instance setup).
notes: >-
nodeCreated fires BEFORE the VueNode Vue component mounts. Extensions that need to access
VueNode-backed state (DOM widgets, Three.js renderers, etc.) must defer to onNodeMounted
(v2) or waitForLoad3d-style callbacks (v1). See BC.37 for the deferred-mount bridge pattern.
Source: Notion Frontend Architecture page (2026-05-08).
member_pattern_ids:
- S2.N1
- S2.N8
usage_weight: 37.56
exemplars:
- pattern_id: S2.N1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
stars: 1787
- pattern_id: S2.N8
repo: Azornes/Comfyui-LayerForge
url: https://github.com/Azornes/Comfyui-LayerForge/blob/main/src/CanvasView.ts#L1401
stars: 313
- pattern_id: S2.N1
repo: SKBv0/ComfyUI_SpideyReroute
url: https://github.com/SKBv0/ComfyUI_SpideyReroute/blob/main/js/SpideyReroute.js#L41
stars: 13
- category_id: BC.02
name: 'Node lifecycle: teardown'
intent: Single de-facto teardown surface for cleaning up DOM widgets, intervals, and observers when a node is removed.
member_pattern_ids:
- S2.N4
usage_weight: 29.35
exemplars:
- pattern_id: S2.N4
repo: Lightricks/ComfyUI-LTXVideo
url: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
stars: 3581
- pattern_id: S2.N4
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js#L348
stars: 2568
- pattern_id: S2.N4
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/ecs-migration-plan.md#L587
stars: 1787
- category_id: BC.03
name: 'Node lifecycle: hydration from saved workflows'
intent: React when a node is rehydrated from a stored workflow; the working replacement for the unused loadedGraphNode hook.
member_pattern_ids:
- S1.H1
- S2.N7
usage_weight: 15.42
exemplars:
- pattern_id: S1.H1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H1
repo: sofakid/dandy
url: https://github.com/sofakid/dandy/blob/main/web/main.js#L114
stars: 54
- pattern_id: S2.N7
repo: akawana/ComfyUI-Folded-Prompts
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L1265
stars: 4
- category_id: BC.04
name: 'Node interaction: pointer, selection, resize'
intent: 'User-driven per-node events: mouse down for custom click regions, selection focus, and resize feedback for relayout.'
member_pattern_ids:
- S2.N10
- S2.N17
- S2.N19
usage_weight: 38.07
exemplars:
- pattern_id: S2.N10
repo: diodiogod/TTS-Audio-Suite
url: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
stars: 906
- pattern_id: S2.N10
repo: melMass/comfy_mtb
url: https://github.com/melMass/comfy_mtb/blob/main/web/comfy_shared.js#L1047
stars: 702
- pattern_id: S2.N10
repo: pixaroma/ComfyUI-Pixaroma
url: https://github.com/pixaroma/ComfyUI-Pixaroma/blob/main/js/compare/index.js#L360
stars: 137
- category_id: BC.05
name: Custom DOM widgets and node sizing
intent: Contribute DOM-backed widgets and override computeSize so the node reserves the right area for them.
member_pattern_ids:
- S4.W2
- S2.N11
usage_weight: 33.35
exemplars:
- pattern_id: S4.W2
repo: Lightricks/ComfyUI-LTXVideo
url: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
stars: 3581
- pattern_id: S4.W2
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/editors/editor_base.js#L511
stars: 2568
- pattern_id: S2.N11
repo: o-l-l-i/ComfyUI-Olm-ImageAdjust
url: https://github.com/o-l-l-i/ComfyUI-Olm-ImageAdjust/blob/main/web/olm_imageadjust.js#L319
stars: 45
- category_id: BC.06
name: Custom canvas drawing (per-node and canvas-level)
intent:
Per-node onDrawForeground and full LGraphCanvas.prototype overrides for badges, indicators, keyboard, and custom
render passes. Includes global ContextMenu replacement (S3.C2) as the most destructive canvas-level override.
v1_scope_note: >-
Simon Tranter (COM-3668, 2025-05-12) explicitly vetoed canvas drawing overrides as "too hacky/specific
to implement APIs for". Confirmed out of v2 v1 scope. S3.C* patterns remain in DB for blast-radius
tracking and strangler-fig planning but v2 need not replace them 1:1. Supports D9 Phase C deferral.
member_pattern_ids:
- S2.N9
- S3.C1
- S3.C2
usage_weight: 58.97
exemplars:
- pattern_id: S3.C1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
stars: 2568
- pattern_id: S3.C1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/simpleTouchSupport.ts#L174
stars: 1787
- pattern_id: S3.C1
repo: melMass/comfy_mtb
url: https://github.com/melMass/comfy_mtb/blob/main/web/note_plus.js#L1
stars: 702
- category_id: BC.07
name: Connection observation, intercept, and veto
intent:
Subscribe to link connect/disconnect events on a node and intercept incoming/outgoing connections before they are
wired to refuse them, mutate slots, or coerce types.
member_pattern_ids:
- S2.N3
- S2.N12
- S2.N13
usage_weight: 51.08
exemplars:
- pattern_id: S2.N13
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
stars: 3049
- pattern_id: S2.N12
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/jsnodes.js#L152
stars: 2568
- pattern_id: S2.N12
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/core/graph/widgets/dynamicWidgets.ts#L539
stars: 1787
- category_id: BC.08
name: Programmatic linking
intent: Extensions wire connections from code (workflow templates, auto-routing).
member_pattern_ids:
- S10.D2
usage_weight: 11.81
exemplars:
- pattern_id: S10.D2
repo: MockbaTheBorg/ComfyUI-Mockba
url: https://github.com/MockbaTheBorg/ComfyUI-Mockba/blob/main/js/slider.js#L1
stars: 1
- pattern_id: S10.D2
repo: vjumpkung/comfyui-infinitetalk-native-sampler
url: https://github.com/vjumpkung/comfyui-infinitetalk-native-sampler/blob/main/README.md#L1
stars: 1
- pattern_id: S10.D2
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138
stars: 0
- category_id: BC.09
name: Dynamic slot and output mutation
intent: Grow/shrink inputs and outputs at runtime, with the obligatory computeSize+setSize reflow that follows.
member_pattern_ids:
- S10.D1
- S10.D3
- S15.OS1
usage_weight: 38.63
exemplars:
- pattern_id: S10.D1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121
stars: 1787
- pattern_id: S10.D1
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-mode-nodes.js#L42
stars: 19
- pattern_id: S15.OS1
repo: yorkane/ComfyUI-KYNode
url: https://github.com/yorkane/ComfyUI-KYNode/blob/main/web/python-editor.js#L243
stars: 10
- category_id: BC.10
name: Widget value subscription
intent: Subscribe to widget value changes either at the widget (callback chain) or node (onWidgetChanged) level.
member_pattern_ids:
- S4.W1
- S2.N14
usage_weight: 32.22
exemplars:
- pattern_id: S2.N14
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317
stars: 1787
- pattern_id: S4.W1
repo: crom8505/ComfyUI-Dynamic-Sigmas
url: https://github.com/crom8505/ComfyUI-Dynamic-Sigmas/blob/main/web/js/graph_sigmas.js#L79
stars: 8
- pattern_id: S4.W1
repo: 834t/ComfyUI_834t_scene_composer
url: https://github.com/834t/ComfyUI_834t_scene_composer/blob/main/js/b34t_scene_composer.js#L148
stars: 5
- category_id: BC.11
name: Widget imperative state writes
intent: Imperatively mutate widget value, COMBO option lists, or the node.widgets array (insert/remove/reorder).
member_pattern_ids:
- S4.W4
- S4.W5
- S2.N16
usage_weight: 28.42
exemplars:
- pattern_id: S2.N16
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
stars: 19
- pattern_id: S4.W4
repo: EnragedAntelope/EA_LMStudio
url: https://github.com/EnragedAntelope/EA_LMStudio/blob/main/web/ea_lmstudio.js#L11
stars: 7
- pattern_id: S4.W4
repo: zzggi2024/shaobkj
url: https://github.com/zzggi2024/shaobkj/blob/main/js/dynamic_inputs.js#L374
stars: 1
- category_id: BC.12
name: Per-widget serialization transform
intent: Transform a widget's value at workflow-serialization time (dynamic prompts, hidden state, expand-on-save).
notes: >-
widget.options.serialize===false widgets (e.g. control_after_generate) still occupy a widgets_values
slot and still fire serializeValue — excluded only from the backend prompt by graphToPrompt(). Test
triple must cover this case explicitly. PR #10392 widgets_values_named is the v2 migration path;
WidgetHandle identity must be by name not position. See research/architecture/widget-serialization-historical-analysis.md.
member_pattern_ids:
- S4.W3
usage_weight: 27.94
exemplars:
- pattern_id: S4.W3
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70
stars: 1787
- pattern_id: S4.W3
repo: Raykosan/ComfyUI_RaykoStudio
url: https://github.com/Raykosan/ComfyUI_RaykoStudio/blob/main/web/rayko_lora_widget.js#L31
stars: 45
- pattern_id: S4.W3
repo: 834t/ComfyUI_834t_scene_composer
url: https://github.com/834t/ComfyUI_834t_scene_composer/blob/main/js/b34t_scene_composer.js#L135
stars: 5
- category_id: BC.13
name: Per-node serialization interception
intent: Intercept node-level serialize/onSerialize to inject custom workflow JSON fields.
notes: >-
Root cause: widgets_values is positional — prototype.serialize patchers consume/produce this array
directly. 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 (backend crash is first visible symptom). Test triple must cover: (a) positional v1
compat, (b) named-map v2 round-trip parity, (c) null-in-numeric-widget logs warning + substitutes
default. PR #11884 guard, PR #10392 named map. See research/architecture/widget-serialization-historical-analysis.md.
member_pattern_ids:
- S2.N6
- S2.N15
usage_weight: 47.07
exemplars:
- pattern_id: S2.N15
repo: Azornes/Comfyui-LayerForge
url: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438
stars: 313
- pattern_id: S2.N15
repo: IAMCCS/IAMCCS-nodes
url: https://github.com/IAMCCS/IAMCCS-nodes/blob/main/web/iamccs_wan_motion_presets.js#L598
stars: 92
- pattern_id: S2.N15
repo: DazzleNodes/ComfyUI-Smart-Resolution-Calc
url: https://github.com/DazzleNodes/ComfyUI-Smart-Resolution-Calc/blob/main/web/utils/serialization.js#L32
stars: 7
- category_id: BC.14
name: Workflow → API serialization interception (graphToPrompt)
intent: Patch app.graphToPrompt to resolve virtual nodes, inject custom metadata, or rewrite the API payload before submit.
member_pattern_ids:
- S6.A1
usage_weight: 46.66
exemplars:
- pattern_id: S6.A1
repo: Comfy-Org/ComfyUI-Manager
url: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781
stars: 14554
- pattern_id: S6.A1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406
stars: 2568
- pattern_id: S6.A1
repo: m3rr/h4_Live
url: https://github.com/m3rr/h4_Live/blob/main/js/h4_datastream.js#L23
stars: 2
- category_id: BC.15
name: Workflow loading into the editor
intent: External/embed scenario where a workflow JSON is pushed into the running editor via app.loadGraphData.
member_pattern_ids:
- S6.A2
usage_weight: 20.31
exemplars:
- pattern_id: S6.A2
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/fixtures/helpers/WorkflowHelper.ts#L215
stars: 1787
- pattern_id: S6.A2
repo: BennyKok/comfyui-deploy
url: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456
stars: 1507
- pattern_id: S6.A2
repo: ketle-man/ComfyUI-Workflow-Studio
url: https://github.com/ketle-man/ComfyUI-Workflow-Studio/blob/main/static/js/workflow-tab.js#L67
stars: 2
- category_id: BC.16
name: Execution output consumption (per-node)
intent: Consume backend execution output on a specific node (text, JSON, image) to drive display.
member_pattern_ids:
- S2.N2
usage_weight: 5.74
exemplars:
- pattern_id: S2.N2
repo: andreszs/ComfyUI-Ultralytics-Studio
url: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9
stars: 3
- pattern_id: S2.N2
repo: AlexZ1967/ComfyUI_ALEXZ_tools
url: https://github.com/AlexZ1967/ComfyUI_ALEXZ_tools/blob/main/web/show_json.js#L49
stars: 0
- pattern_id: S2.N2
repo: becky3/comfyui-workspace
url: https://github.com/becky3/comfyui-workspace/blob/main/custom_nodes/ComfyUI-Becky3-Common/js/show_text.js#L33
stars: 0
- category_id: BC.17
name: Backend execution lifecycle and progress events
intent: Subscribe to api.addEventListener for execution_*, progress, status, and reconnecting events.
member_pattern_ids:
- S5.A1
- S5.A2
- S5.A3
usage_weight: 51.25
exemplars:
- pattern_id: S5.A2
repo: AIGODLIKE/AIGODLIKE-ComfyUI-Studio
url: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39
stars: 405
- pattern_id: S5.A3
repo: kyuz0/amd-strix-halo-comfyui-toolboxes
url: https://github.com/kyuz0/amd-strix-halo-comfyui-toolboxes/blob/main/scripts/benchmark_workflows.py#L52
stars: 109
- pattern_id: S5.A1
repo: ShakerSmith/ShakerNodesSuite
url: https://github.com/ShakerSmith/ShakerNodesSuite/blob/main/js/shaker_preview_ui.js#L58
stars: 8
- category_id: BC.18
name: Backend HTTP calls
intent: Call ComfyAPI.fetchApi as the canonical authenticated path to backend HTTP endpoints.
member_pattern_ids:
- S6.A3
usage_weight: 22.74
exemplars:
- pattern_id: S6.A3
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61
stars: 1787
- pattern_id: S6.A3
repo: akawana/ComfyUI-Folded-Prompts
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L1227
stars: 4
- pattern_id: S6.A3
repo: zhupeter010903/ComfyUI-XYZ-prompt-library
url: https://github.com/zhupeter010903/ComfyUI-XYZ-prompt-library/blob/main/js/prompt_library_window.js#L1379
stars: 1
- category_id: BC.19
name: Workflow execution trigger
intent: Trigger or intercept queuePrompt for sidebar Run buttons, auth tokens, or payload mutation.
member_pattern_ids:
- S6.A4
usage_weight: 12.65
exemplars:
- pattern_id: S6.A4
repo: MajoorWaldi/ComfyUI-Majoor-AssetsManager
url: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317
stars: 97
- pattern_id: S6.A4
repo: gigici/ComfyUI_BlendPack
url: https://github.com/gigici/ComfyUI_BlendPack/blob/main/js/ui/NodeUI.js#L99
stars: 1
- pattern_id: S6.A4
repo: rohapa/comfyui-replay
url: https://github.com/rohapa/comfyui-replay/blob/main/README.md#L497
stars: 0
- category_id: BC.20
name: Custom node-type registration (frontend-only / virtual)
intent: Register pure-frontend or fully virtual node types and mark them with isVirtualNode so the backend ignores them.
member_pattern_ids:
- S1.H5
- S1.H6
- S8.P1
usage_weight: 27.49
exemplars:
- pattern_id: S1.H6
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/rerouteNode.ts
stars: 1787
- pattern_id: S1.H5
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H6
repo: sofakid/dandy
url: https://github.com/sofakid/dandy/blob/main/web/main.js#L111
stars: 54
- category_id: BC.21
name: Custom widget-type registration
intent: Register new widget types (color picker, file uploader, custom inputs) via getCustomWidgets.
member_pattern_ids:
- S1.H2
usage_weight: 7.17
exemplars:
- pattern_id: S1.H2
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H2
repo: haohaocreates/PR-rk-comfy-nodes-36d8f0a5
url: https://github.com/haohaocreates/PR-rk-comfy-nodes-36d8f0a5/blob/main/web/rk_nodes.ts#L22
stars: 0
- category_id: BC.22
name: Context menu contributions (node and canvas)
intent:
Contribute right-click menu items at both the node and canvas scope, including legacy prototype patches and the
supported v1 hooks.
member_pattern_ids:
- S2.N5
- S1.H3
- S1.H4
usage_weight: 19.53
exemplars:
- pattern_id: S1.H3
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H4
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H3
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-canvas-utils.js#L2
stars: 19
- category_id: BC.23
name: Node property bag mutations
intent: React to mutations of node.properties — the persistent property bag that survives serialization.
member_pattern_ids:
- S2.N18
usage_weight: 14.42
exemplars:
- pattern_id: S2.N18
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78
stars: 3049
- pattern_id: S2.N18
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/seed.js#L26
stars: 3049
- pattern_id: S2.N18
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/power_primitive.js#L142
stars: 3049
- category_id: BC.24
name: Node-def schema inspection
intent: Branch on ComfyNodeDef shape (input.required/optional/hidden, output, output_node, category) to drive UI.
member_pattern_ids:
- S13.SC1
usage_weight: 22.43
exemplars:
- pattern_id: S13.SC1
repo: BennyKok/comfyui-deploy
url: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1
stars: 1507
- pattern_id: S13.SC1
repo: StableLlama/ComfyUI-basic_data_handling
url: https://github.com/StableLlama/ComfyUI-basic_data_handling/blob/main/web/js/dynamicnode.js#L1
stars: 43
- pattern_id: S13.SC1
repo: xeinherjer-dev/ComfyUI-XENodes
url: https://github.com/xeinherjer-dev/ComfyUI-XENodes/blob/main/web/js/combo_selector.js#L1
stars: 1
- category_id: BC.25
name: Shell UI registration (commands, sidebars, toasts)
intent: Declarative shell-UI contributions through extensionManager / commandManager / sidebarTab / bottomPanel.
member_pattern_ids:
- S12.UI1
usage_weight: 10.98
exemplars:
- pattern_id: S12.UI1
repo: robertvoy/ComfyUI-Distributed
url: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269
stars: 544
- pattern_id: S12.UI1
repo: maxi45274/ComfyUI_LinkFX
url: https://github.com/maxi45274/ComfyUI_LinkFX/blob/main/js/LinkFX.js#L707
stars: 3
- pattern_id: S12.UI1
repo: criskb/Comfypencil
url: https://github.com/criskb/Comfypencil/blob/main/web/comfy_pencil_extension.js#L955
stars: 0
- category_id: BC.26
name: Globals as ABI (window.LiteGraph, window.comfyAPI)
intent: Reach into the global namespace for LiteGraph constructors/enums or the module-as-global comfyAPI registry.
member_pattern_ids:
- S7.G1
usage_weight: 27.0
exemplars:
- pattern_id: S7.G1
repo: ryanontheinside/ComfyUI_RyanOnTheInside
url: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1
stars: 801
- pattern_id: S7.G1
repo: ArtHommage/HommageTools
url: https://github.com/ArtHommage/HommageTools/blob/main/web/js/index.js#L1
stars: 4
- pattern_id: S7.G1
repo: PROJECTMAD/PROJECT-MAD-NODES
url: https://github.com/PROJECTMAD/PROJECT-MAD-NODES/blob/main/web/js/index.js#L1
stars: 4
- category_id: BC.27
name: LiteGraph entity direct manipulation (reroute, group, link, slot)
intent: Direct read/mutation of reroutes, groups, links, and slots — no public extension API exists today.
member_pattern_ids:
- S9.R1
- S9.G1
- S9.L1
- S9.S1
usage_weight: 39.37
exemplars:
- pattern_id: S9.R1
repo: nodetool-ai/nodetool
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
stars: 330
- pattern_id: S9.S1
repo: nodetool-ai/nodetool
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L267
stars: 330
- pattern_id: S9.S1
repo: Stibo/comfyui-nifty-nodes
url: https://github.com/Stibo/comfyui-nifty-nodes/blob/main/js/nifty_nodes.js#L112
stars: 3
- category_id: BC.28
name: Subgraph fan-out via set/get virtual nodes
intent: Fan out a single named value across the graph without explicit links (KJNodes-style Set/Get nodes).
member_pattern_ids:
- S9.SG1
usage_weight: 16.89
exemplars:
- pattern_id: S9.SG1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406
stars: 2568
- pattern_id: S9.SG1
repo: krismasdev/ComfyUI-Flux-Continuum
url: https://github.com/krismasdev/ComfyUI-Flux-Continuum/blob/main/web/hint.js#L1
stars: 0
- pattern_id: S9.SG1
repo: SpaceWarpStudio/ComfyUI-SetInputGetOutput
url: https://github.com/SpaceWarpStudio/ComfyUI-SetInputGetOutput/blob/main/web/js/setinputgetoutput.js#L1
stars: 0
- category_id: BC.29
name: Graph enumeration, mutation, and cross-scope identity
intent:
Enumerate or mutate the node set (graph.add/remove/findNodesByType/serialize/configure) and resolve cross-subgraph
references via NodeLocatorId / NodeExecutionId.
member_pattern_ids:
- S11.G2
- S14.ID1
usage_weight: 23.56
exemplars:
- pattern_id: S11.G2
repo: yolain/ComfyUI-Easy-Use
url: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easyExtraMenu.js#L439
stars: 2503
- pattern_id: S11.G2
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/tests/workflowPersistence.spec.ts#L351
stars: 1787
- pattern_id: S11.G2
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-ui-enhancements.js#L29
stars: 19
- category_id: BC.30
name: Graph change tracking, batching, and reactivity flush
intent:
'Coordinate graph-level change: graph._version monotonic counter, beforeChange/afterChange batching, and the imperative
setDirtyCanvas redraw flush.'
member_pattern_ids:
- S11.G1
- S11.G3
- S11.G4
usage_weight: 34.38
exemplars:
- pattern_id: S11.G3
repo: nodetool-ai/nodetool
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
stars: 330
- pattern_id: S11.G4
repo: akawana/ComfyUI-Folded-Prompts
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L776
stars: 4
- pattern_id: S11.G3
repo: linjm8780860/ljm_comfyui
url: https://github.com/linjm8780860/ljm_comfyui/blob/main/src/utils/vintageClipboard.ts#L1
stars: 0
- category_id: BC.31
name: DOM injection and style management
intent:
Extensions add UI chrome, toolbars, and style overrides directly into the document outside any provided API —
style tags into head, arbitrary elements into body, innerHTML rendering, and external script loading.
member_pattern_ids:
- S16.DOM1
- S16.DOM2
- S16.DOM3
- S16.DOM4
usage_weight: 0.0
notes:
'usage_weight pending rollup-blast-radius.py re-run after database.yaml merge (I-N4.1). Notion counts: DOM1=354
occ, DOM2=364 occ, DOM3=443 occ, DOM4=232 occ across ~81 packages — among the highest raw occurrence counts in
the entire dataset. v2 replacements: injectStyles(), addPanel(), addToolbarItem(), safe HTML rendering API.'
exemplars:
- pattern_id: S16.DOM1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js
stars: 2568
- pattern_id: S16.DOM2
repo: yolain/ComfyUI-Easy-Use
url: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easy.js
stars: 2503
- pattern_id: S16.DOM3
repo: '(aggregate — Notion §2.3)'
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
stars: 0
- category_id: BC.32
name: Embedded framework runtimes and Vue widget bundling
intent:
Extensions bundle their own copy of Vue (or another framework) inside a DOM widget, bypassing the host app
instance and losing access to shared stores, i18n, and theme.
member_pattern_ids:
- S16.VUE1
usage_weight: 0.0
notes:
'usage_weight pending rollup-blast-radius.py re-run. 9 packages confirmed (Notion §2.9). v2 replacement:
registerVueWidget(nodeType, name, Component) sharing host Vue instance — already in plans/P1 §5 Custom widget type.
This BC provides the evidence base for that P1 design decision.'
exemplars:
- pattern_id: S16.VUE1
repo: ComfyUI-NKD-Sigmas-Curve
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
stars: 0
- pattern_id: S16.VUE1
repo: '(aggregate — 9 packages, Notion §2.9)'
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
stars: 0
# ── Categories added 2026-05-08 from Notion COM-3668 (Simon Tranter, Custom Scripts API requirements) ──
- category_id: BC.33
name: Cross-extension DOM widget creation observation
intent:
An extension observes when *any* DOM widget is created (by any other extension) so it can attach its own
listeners — the mechanism the Autocomplete extension needs to wire its input handler to every text widget.
member_pattern_ids:
- S4.W6
usage_weight: 0.0
notes: >-
Identified from COM-3668. Distinct from BC.05 (creating DOM widgets) and BC.10 (subscribing to value changes).
Gap: no v1 hook fires for cross-extension widget creation observation. v2 shape: onDOMWidgetCreated(handler)
in defineExtension setup context. usage_weight pending blast-radius re-run.
source: notion-COM-3668
exemplars:
- pattern_id: S4.W6
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
stars: 0
- category_id: BC.34
name: Settings-panel custom dialog integration
intent: Extensions open custom modal dialogs triggered from the settings panel, rather than injecting raw DOM.
member_pattern_ids:
- S12.UI3
usage_weight: 0.0
notes: >-
Identified from COM-3668. Currently worked around via S16.DOM3 innerHTML injection. Distinct from S12.UI1
(sidebar/command registration) — this is about dialog lifecycle tied to settings entries. v2 shape:
app.ui.openDialog(Component) or settings entry type 'dialog-trigger'. usage_weight pending blast-radius re-run.
source: notion-COM-3668
exemplars:
- pattern_id: S12.UI3
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
stars: 0
- category_id: BC.35
name: Pre-queue widget validation
intent:
Validate widget values before a workflow is submitted and surface typed errors to the user — rejecting
the queue rather than silently mutating or failing.
member_pattern_ids:
- S6.A5
usage_weight: 0.0
notes: >-
Identified from COM-3668. Currently worked around via S6.A4 queuePrompt monkey-patching (silent_breakage=true
when multiple extensions patch). Distinct from D5 beforeSerialize (transforms values) and BC.19 (triggers
execution). v2 needs explicit beforeQueue event with event.reject(message). usage_weight pending re-run.
source: notion-COM-3668
exemplars:
- pattern_id: S6.A5
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
stars: 0
- category_id: BC.36
name: PrimeVue widget component API surface
intent: >-
Custom node authors configuring widget behavior via per-component prop subsets — the v2 replacement
for direct widget.options mutation (S4.W4, S4.W1) and DOM widget construction (S4.W5, S4.W6).
15 PrimeVue components are the authoritative widget-kind enumeration for v2.
member_pattern_ids:
- S4.W1
- S4.W4
- S4.W5
notes: >-
Source: Notion page "Widget Component APIs" (2026-05-08). 15 components: Button, InputText, Select,
ColorPicker, MultiSelect, SelectButton, Slider, Textarea, ToggleSwitch, Chart, Image, ImageCompare,
Galleria, FileUpload, TreeSelect. Exclusion rule (Pablo): strip style/class/dt/pt/*Class/*Style.
ToggleSwitch is the only component with completed Pick<> types so far (WIP).
Informs: D7 typed options bags (future pivot), I-TF.2 widget-kind test triples,
PKG2 WidgetHandle.getOption key surface. disabled/readonly map to D7 first-class fields,
not options bag.
usage_weight: 0.0
exemplars:
- pattern_id: S4.W4
repo: '(see database.yaml S4.W4 exemplars — widget.options.values mutation)'
url: https://www.notion.so/comfy-org/Widget-Component-APIs-2126d73d365080b0bf30f241c09dd756
stars: 0
- category_id: BC.37
name: VueNode bridge timing — deferred mount access
intent: >-
Extensions that register in nodeCreated but need to access Vue-component-backed state
(Three.js renderer, DOM widget, ComponentWidgetImpl value) must defer until the Vue
component's onMounted fires. The v1 pattern is waitForLoad3d(node, cb); the v2 pattern
is onNodeMounted(() => { ... }) inside defineNodeExtension.
member_pattern_ids:
- S4.W5
notes: >-
Source: Notion Frontend Architecture page 3536d73d (2026-05-08). nodeCreated gives the
LiteGraph node; the VueNode Vue component has NOT mounted yet. waitForLoad3d in
src/extensions/core/Load3D is the canonical v1 fixture. ComponentWidgetImpl dual-identity:
LiteGraph side (value/callback/name) vs Vue side (props/emits/lifecycle).
v2 contract: onNodeMounted() hook fires after Vue component mount — this is the correct
timing for accessing VueNode-backed resources.
Informs: I-SR.2.B2 (NodeInstanceScope must not sync-access VueNode at setup time),
I-TF.3.C1 (harness must simulate two-phase mount), I-TF.2 test triple for BC.37.
D8 relevance: app.rootGraph is not reactive (confirmed by this doc) — the exact gap D8 solves.
usage_weight: 0.0
source: notion-frontend-architecture-3536d73d
exemplars:
- pattern_id: S4.W5
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/load3d.ts
stars: 1787
- category_id: BC.38
name: Canvas mode observation
intent: >-
Detect and react to ComfyUI canvas mode transitions (graph / app / builder:inputs /
builder:outputs / builder:arrange). Custom nodes that adapt rendering, widget resize
behavior, or read-only state across modes need a stable event — not polling or heuristics
against internal Pinia store state.
member_pattern_ids:
- S17.AM1
mechanism: absent-api
notes: >-
appModeStore is a Pinia composable; JS extensions cannot use Vue composables. v2 gap:
no node.on('canvasModeChanged') exists yet in node.ts — distinct from NodeModeChangedEvent
(execution mode only). v2 contract: app-level or node-level canvasModeChanged event.
Flagged: Terry DX walkthrough A.1. Informs: node.ts overloads (add canvasModeChanged
or document as known gap), I-TF.2 test triple for BC.38.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.AM1
repo: (first-principles assessment — Terry Jia)
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
stars: 0
- category_id: BC.39
name: Subgraph boundary event propagation
intent: >-
Custom node callbacks (onExecuted, MatchType, autogrow onConnectionsChange, promoted widget
callbacks) that must propagate across subgraph boundaries. Four distinct silent-failure modes
when custom nodes are placed inside subgraphs.
member_pattern_ids:
- S17.SB1
mechanism: absent-api
notes: >-
Requires D9 Phase B (post-Alex rebase on #11939). ECS substrate must forward SubgraphNode
execution events from internal nodes. MatchType and autogrow propagation require subgraph
boundary awareness in World dispatcher. Blocked: I-PG.B1. Short-term: @experimental on
affected NodeHandle events; subgraphCompatible flag in NodeExtensionOptions.
Intersects: ADR 0006 (I-NEW.1), Austin's fix-linked-widget-promotion.
Flagged: Terry DX walkthrough A.2.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.SB1
repo: (first-principles assessment — Terry Jia)
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
stars: 0
- category_id: BC.40
name: File upload and asset URL construction
intent: >-
Upload files to ComfyUI backend and construct retrieval URLs. 32+ packages duplicate this
pattern from scratch — FormData construction, fetchApi('/upload/image'), /view?filename URL
assembly. A helper API would collapse this to comfyAPI.uploadFile() + comfyAPI.getFileUrl().
member_pattern_ids:
- S17.FA1
mechanism: absent-api
notes: >-
Out of scope for @comfyorg/extension-api (node extension surface). Belongs in future
@comfyorg/comfy-api package. 32+ packages affected; 9 implement video upload variants.
Upload timeout hardcoded 120s; large 3D/video fail silently. No temp file lifecycle.
Document as known gap in src/extension-api/README.md.
Flagged: Terry DX walkthrough A.3.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.FA1
repo: (first-principles assessment — Terry Jia)
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
stars: 0
- category_id: BC.41
name: Widget values positional serialization fragility
intent: >-
Widget values serialized as positional array [v1, v2, v3] instead of named dict.
Any input definition change (add, reorder, rename, remove, required→optional) silently
misaligns values when loading existing workflows. Root cause of #1 user complaint:
"my workflow broke after I updated the custom node."
member_pattern_ids:
- S17.WV1
mechanism: positional-array
notes: >-
Blocked on workflow-schema-migration (out of v2 surface scope). D7 Part 4 (4→2
serialization collapse) + beforeSerialize as partial mitigation. Long-term fix: named dict
format { widgetName: value } — breaking JSON schema change requiring versioning +
migrateWidgetValues() callback. PR #10392 added widgets_values_named opt-in; PR #11884
null guard. v2 contract: name-keyed identity (WidgetHandle by name not position).
Intersects: ADR 0006, widget-serialization-historical-analysis.md, Austin's work.
Flagged: Terry DX walkthrough A.4.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.WV1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/utils/nodeDefOrderingUtil.ts
stars: 1787

File diff suppressed because it is too large Load Diff

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env python3
"""
Compat-floor gate: Verify all high-impact behavior categories have test triples.
Per PLAN.md §Compat-floor: "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 +
migration tests before v2 ships."
This script:
1. Reads research/touch-points/behavior-categories.yaml
2. Finds all categories with usage_weight >= 2.0 (blast_radius threshold)
3. Checks that each has all three test files: bc-XX.v1.test.ts, bc-XX.v2.test.ts, bc-XX.migration.test.ts
4. Exits 0 if all present, exits 1 if any missing (fails CI)
Usage: python3 scripts/check-compat-floor.py
"""
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("ERROR: PyYAML not installed. Run: pip install pyyaml", file=sys.stderr)
sys.exit(1)
COMPAT_FLOOR_THRESHOLD = 2.0
BEHAVIOR_CATEGORIES_PATH = Path("research/touch-points/behavior-categories.yaml")
TESTS_DIR = Path("src/extension-api-v2/__tests__")
def main():
# Check that behavior-categories.yaml exists
if not BEHAVIOR_CATEGORIES_PATH.exists():
print(f"ERROR: {BEHAVIOR_CATEGORIES_PATH} not found", file=sys.stderr)
print(" Run scripts/build-behavior-categories.py first or copy from workspace", file=sys.stderr)
sys.exit(1)
# Skip check if tests directory doesn't exist (tests only in tf branch)
if not TESTS_DIR.exists():
print(f"SKIP: {TESTS_DIR} not found — compat-floor tests not yet added to this branch")
print(" The compat-floor gate only enforces on branches with extension-api-v2 tests.")
sys.exit(0)
# Load categories
with open(BEHAVIOR_CATEGORIES_PATH, "r") as f:
data = yaml.safe_load(f)
categories = data.get("categories", [])
# Find categories above compat floor
above_floor = []
for cat in categories:
cat_id = cat.get("category_id", "")
usage_weight = cat.get("usage_weight", 0)
if usage_weight >= COMPAT_FLOOR_THRESHOLD:
above_floor.append({
"id": cat_id,
"name": cat.get("name", ""),
"usage_weight": usage_weight
})
print(f"Compat-floor check: {len(above_floor)} categories with usage_weight >= {COMPAT_FLOOR_THRESHOLD}")
print()
# Check each category for test triples
missing = []
for cat in above_floor:
cat_id = cat["id"]
# Extract number from BC.XX
num_str = cat_id.replace("BC.", "").zfill(2)
required_files = [
f"bc-{num_str}.v1.test.ts",
f"bc-{num_str}.v2.test.ts",
f"bc-{num_str}.migration.test.ts"
]
cat_missing = []
for fname in required_files:
fpath = TESTS_DIR / fname
if not fpath.exists():
cat_missing.append(fname)
if cat_missing:
missing.append({
"category": cat_id,
"name": cat["name"],
"usage_weight": cat["usage_weight"],
"missing": cat_missing
})
status = "❌ MISSING"
else:
status = ""
print(f" {cat_id} ({cat['usage_weight']:.2f}) {cat['name'][:40]:<40} {status}")
if cat_missing:
for m in cat_missing:
print(f" └─ {m}")
print()
if missing:
print(f"FAIL: {len(missing)} categories missing test files", file=sys.stderr)
print()
print("Per PLAN.md §Compat-floor, all blast_radius >= 2.0 categories", file=sys.stderr)
print("must have complete test triples (v1, v2, migration) before v2 ships.", file=sys.stderr)
print()
print("Missing files:", file=sys.stderr)
for m in missing:
for f in m["missing"]:
print(f" - {TESTS_DIR / f}", file=sys.stderr)
sys.exit(1)
else:
print(f"PASS: All {len(above_floor)} compat-floor categories have test triples")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# PKG5.D6 — Generate TypeDoc → Mintlify MDX for @comfyorg/extension-api
#
# Output: packages/extension-api/docs-build/mintlify/*.mdx
# packages/extension-api/docs-build/mintlify/nav-snippet.json
#
# Prerequisites: pnpm install must have been run (typedoc, tsx)
# Usage: ./scripts/generate-docs.sh [--watch]
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_DIR="$REPO_ROOT/packages/extension-api"
if [ ! -f "$PKG_DIR/package.json" ]; then
echo "ERROR: $PKG_DIR/package.json not found — run from repo root or ensure packages/extension-api exists." >&2
exit 1
fi
if [ "${1:-}" = "--watch" ]; then
echo "Starting docs watch mode..."
pnpm --filter @comfyorg/extension-api docs:watch
else
echo "Generating extension API docs..."
pnpm --filter @comfyorg/extension-api docs:build
echo ""
echo "Done. MDX files written to: $PKG_DIR/docs-build/mintlify/"
echo "Copy to Comfy-Org/docs: cp -r $PKG_DIR/docs-build/mintlify/* <docs-repo>/extensions/api/"
fi

View File

@@ -4,6 +4,7 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -43,4 +44,29 @@ describe('comfyCredits helpers', () => {
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
test('clamps minimumFractionDigits when maximumFractionDigits is lower than default', () => {
expect(
formatCredits({
value: 1.5,
locale: 'en-US',
numberOptions: { maximumFractionDigits: 0 }
})
).toBe('2')
expect(
formatUsd({
value: 3.456,
locale: 'en-US',
numberOptions: { maximumFractionDigits: 1 }
})
).toBe('3.5')
})
test('clampUsd clamps values to the allowed purchase range', () => {
expect(clampUsd(50)).toBe(50)
expect(clampUsd(0.5)).toBe(1)
expect(clampUsd(2000)).toBe(1000)
expect(clampUsd(NaN)).toBe(0)
})
})

View File

@@ -1,162 +0,0 @@
// Category: BC.01 — Node lifecycle: creation
// DB cross-ref: S2.N1, S2.N8
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 nodeCreated(node) + beforeRegisterNodeDef → v2 defineNode({ nodeCreated(handle) })
//
// Phase A strategy: test behavioral equivalence between v1 and v2 patterns
// using local stubs. Real ECS dispatch (Phase B) is marked it.todo.
import { describe, expect, it } from 'vitest'
// ── Shared harness ────────────────────────────────────────────────────────────
// Pilot migration off inline createV1App / createV2Runtime blocks.
// See `harness/README.md` for the broader rollout plan.
import { createV1App } from './harness/v1App'
import { createV2Runtime as createSharedV2Runtime } from './harness/v2Runtime'
const createV2Runtime = () => {
const rt = createSharedV2Runtime({ idPrefix: 'mig-test' })
// Migration tests historically called `mountNode(comfyClass)` directly.
// Bridge to the shared runtime's `addNode` + `mountNode(id)` shape so
// the rest of the file is left untouched.
return {
register: rt.register,
mountNode: (comfyClass: string, isLoaded = false) => {
const id = rt.addNode(comfyClass)
rt.mountNode(id, isLoaded)
return id
},
clear: rt.clear
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.01 migration — node lifecycle: creation', () => {
describe('nodeCreated call-count parity (S2.N1)', () => {
it('v1 and v2 nodeCreated are both called once per node created', () => {
const v1 = createV1App()
const v2 = createV2Runtime()
let v2Count = 0
v1.registerExtension({ name: 'parity', nodeCreated() {} })
v2.register({
name: 'bc01.mig.parity',
nodeCreated() {
v2Count++
}
})
const types = ['KSampler', 'KSampler', 'CLIPTextEncode']
types.forEach((t, i) => v1.simulateNodeCreated({ id: i, type: t }))
types.forEach((t) => v2.mountNode(t))
expect(v2Count).toBe(v1.totalCreated)
expect(v2Count).toBe(3)
})
it('v2 nodeCreated fires in lexicographic name order (D10b tie-break)', () => {
const v2 = createV2Runtime()
const order: string[] = []
v2.register({
name: 'bc01.mig.z-ext',
nodeCreated() {
order.push('z-ext')
}
})
v2.register({
name: 'bc01.mig.a-ext',
nodeCreated() {
order.push('a-ext')
}
})
v2.register({
name: 'bc01.mig.m-ext',
nodeCreated() {
order.push('m-ext')
}
})
v2.mountNode('TestNode')
expect(order).toEqual(['a-ext', 'm-ext', 'z-ext'])
})
})
describe('beforeRegisterNodeDef type-guard → nodeTypes filter (S2.N8)', () => {
it('v2 nodeTypes filter produces identical per-type call counts as v1 type-guard pattern', () => {
const v1 = createV1App()
const v2 = createV2Runtime()
const v1Received: string[] = []
const v2Received: string[] = []
// v1: explicit type-guard inside callback
v1.registerExtension({
name: 'type-guard',
nodeCreated(node) {
if (node.type === 'KSampler') v1Received.push(node.type)
}
})
// v2: declarative filter
v2.register({
name: 'bc01.mig.type-filter',
nodeTypes: ['KSampler'],
nodeCreated(h) {
v2Received.push(h.type)
}
})
const types = ['KSampler', 'CLIPTextEncode', 'KSampler']
types.forEach((t, i) => v1.simulateNodeCreated({ id: i, type: t }))
types.forEach((t) => v2.mountNode(t))
expect(v2Received).toEqual(v1Received)
expect(v2Received).toEqual(['KSampler', 'KSampler'])
})
it('excluded types receive no v2 nodeCreated call, matching v1 type-guard exclusion', () => {
const v2 = createV2Runtime()
const received: string[] = []
v2.register({
name: 'bc01.mig.exclude',
nodeTypes: ['KSampler'],
nodeCreated(h) {
received.push(h.type)
}
})
v2.mountNode('Note')
expect(received).toHaveLength(0)
})
})
describe('D12 reset-to-fresh on copy/paste', () => {
it('copy/paste (new entityId) triggers fresh nodeCreated, not a clone of source state', () => {
const v2 = createV2Runtime()
let setupCount = 0
v2.register({
name: 'bc01.mig.fresh-copy',
nodeCreated() {
setupCount++
}
})
v2.mountNode('TestNode') // source
expect(setupCount).toBe(1)
v2.mountNode('TestNode') // paste → new entityId → fresh setup
expect(setupCount).toBe(2)
})
})
describe('VueNode mount timing invariant', () => {
it.todo(
// Phase B: requires two-phase harness simulation (BC.37).
'both v1 and v2 nodeCreated fire before VueNode mounts — runtime proof deferred to Phase B'
)
})
})

View File

@@ -1,153 +0,0 @@
// 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, expect, it, vi } from 'vitest'
import {
createMiniComfyApp,
countEvidenceExcerpts,
loadEvidenceSnippet,
runV1
} from '../harness'
describe('BC.01 v1 contract — node lifecycle: creation', () => {
describe('S2.N1 — evidence excerpts', () => {
it('S2.N1 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N1')).toBeGreaterThan(0)
})
it('S2.N1 evidence snippet contains nodeCreated fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N1', 0)
expect(snippet).toMatch(/nodeCreated/i)
})
it('S2.N1 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N1', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N8 — evidence excerpts', () => {
it('S2.N8 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N8')).toBeGreaterThan(0)
})
it('S2.N8 evidence snippet contains prototype-patching fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N8', 0)
expect(snippet).toMatch(/nodeType\.prototype/i)
})
it('S2.N8 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N8', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N1 — nodeCreated hook (synthetic)', () => {
it('nodeCreated callback receives node as first arg', () => {
const received: unknown[] = []
const extension = {
nodeCreated: vi.fn((node: unknown) => received.push(node))
}
const fakeNode = { id: 1, type: 'KSampler' }
extension.nodeCreated(fakeNode)
expect(extension.nodeCreated).toHaveBeenCalledOnce()
expect(received[0]).toBe(fakeNode)
})
it('properties set on node inside nodeCreated are accessible after the call', () => {
const fakeNode: Record<string, unknown> = {
id: 2,
type: 'CLIPTextEncode'
}
const extension = {
nodeCreated(node: Record<string, unknown>) {
node.customTag = 'injected-by-extension'
}
}
extension.nodeCreated(fakeNode)
expect(fakeNode.customTag).toBe('injected-by-extension')
})
it('nodeCreated fires for each registered extension (2 extensions = 2 calls)', () => {
const fakeNode = { id: 3, type: 'VAEDecode' }
const callOrder: string[] = []
const extA = {
nodeCreated: vi.fn((_node: unknown) => callOrder.push('A'))
}
const extB = {
nodeCreated: vi.fn((_node: unknown) => callOrder.push('B'))
}
// Simulate the app dispatching nodeCreated to all registered extensions
for (const ext of [extA, extB]) {
ext.nodeCreated(fakeNode)
}
expect(extA.nodeCreated).toHaveBeenCalledOnce()
expect(extB.nodeCreated).toHaveBeenCalledOnce()
expect(callOrder).toEqual(['A', 'B'])
})
it.todo('fires before node is added to graph')
it.todo('fires before VueNode mounts')
})
describe('S2.N8 — beforeRegisterNodeDef hook (synthetic)', () => {
it('beforeRegisterNodeDef patches the prototype; all instances after the patch have the method', () => {
function FakeNodeType(this: Record<string, unknown>) {
this.id = Math.random()
}
FakeNodeType.prototype = {}
FakeNodeType.type = 'KSampler'
// Extension patches the prototype inside beforeRegisterNodeDef
function beforeRegisterNodeDef(nodeType: {
prototype: Record<string, unknown>
}) {
nodeType.prototype.myExtensionMethod = function () {
return 'patched'
}
}
beforeRegisterNodeDef(FakeNodeType)
const instanceA = Object.create(FakeNodeType.prototype) as Record<
string,
unknown
>
const instanceB = Object.create(FakeNodeType.prototype) as Record<
string,
unknown
>
expect(typeof instanceA.myExtensionMethod).toBe('function')
expect(typeof instanceB.myExtensionMethod).toBe('function')
expect((instanceA.myExtensionMethod as () => string)()).toBe('patched')
})
it('beforeRegisterNodeDef callback receives nodeType name as first argument', () => {
const receivedNames: string[] = []
function beforeRegisterNodeDef(nodeType: { type: string }) {
receivedNames.push(nodeType.type)
}
const fakeNodeType = { type: 'KSampler', prototype: {} }
beforeRegisterNodeDef(fakeNodeType)
expect(receivedNames).toContain('KSampler')
})
})
})

View File

@@ -1,237 +0,0 @@
// 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: defineNode({ 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.
//
// Phase A strategy: test the API *shape* and *contract* using a local stub that
// mirrors the real service. The real mountExtensionsForNode depends on @/world/* (ECS)
// which lands in Phase B. Phase B tests are marked it.todo(Phase B).
import { describe, expect, it } from 'vitest'
import type { NodeExtensionOptions } from '@/extension-api/lifecycle'
import type { NodeHandle } from '@/extension-api/node'
import type { NodeEntityId } from '@/world/entityIds'
// ── Shared harness ────────────────────────────────────────────────────────────
// Pilot migration off the inline createTestRuntime block — see
// `harness/README.md` for the broader rollout. When Phase B lands, these
// tests are replaced/supplemented by ones that import the real
// mountExtensionsForNode with the mocked world.
import { createV2Runtime } from './harness/v2Runtime'
const createTestRuntime = () => createV2Runtime({ idPrefix: 'graph-test' })
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.01 v2 contract — node lifecycle: creation', () => {
describe('NodeExtensionOptions shape — defineNode API', () => {
it('NodeExtensionOptions accepts a nodeCreated callback with NodeHandle parameter', () => {
// Type-level proof: this compiles = the contract is correctly shaped.
const options: NodeExtensionOptions = {
name: 'bc01.shape',
nodeCreated(_node: NodeHandle) {
// callback receives NodeHandle
}
}
expect(options.name).toBe('bc01.shape')
expect(typeof options.nodeCreated).toBe('function')
})
it('NodeExtensionOptions accepts nodeTypes filter array', () => {
const options: NodeExtensionOptions = {
name: 'bc01.types',
nodeTypes: ['KSampler', 'KSamplerAdvanced'],
nodeCreated(_node) {}
}
expect(options.nodeTypes).toEqual(['KSampler', 'KSamplerAdvanced'])
})
it('nodeTypes is optional — omitting it means global registration', () => {
const options: NodeExtensionOptions = {
name: 'bc01.global',
nodeCreated(_node) {}
}
expect(options.nodeTypes).toBeUndefined()
})
})
describe('nodeCreated(handle) — per-instance setup', () => {
it('nodeCreated is called once per node instance', () => {
const rt = createTestRuntime()
const calls: NodeHandle[] = []
rt.register({
name: 'bc01.creation-once',
nodeCreated(h) {
calls.push(h)
}
})
const id = rt.addNode('TestNode')
rt.mountNode(id)
expect(calls).toHaveLength(1)
})
it('NodeHandle.id matches the node being created', () => {
const rt = createTestRuntime()
let capturedId: NodeEntityId | undefined
rt.register({
name: 'bc01.entity-id',
nodeCreated(h) {
capturedId = h.id as unknown as NodeEntityId
}
})
const id = rt.addNode('TestNode')
rt.mountNode(id)
expect(capturedId).toBe(id)
})
it('NodeHandle.type returns the comfyClass of the node', () => {
const rt = createTestRuntime()
let capturedType: string | undefined
rt.register({
name: 'bc01.type-read',
nodeCreated(h) {
capturedType = h.type
}
})
const id = rt.addNode('KSampler')
rt.mountNode(id)
expect(capturedType).toBe('KSampler')
})
it('nodeCreated fires separately for each node instance — independent calls', () => {
const rt = createTestRuntime()
let callCount = 0
rt.register({
name: 'bc01.multi-instance',
nodeCreated() {
callCount++
}
})
rt.mountNode(rt.addNode('TestNode'))
rt.mountNode(rt.addNode('TestNode'))
expect(callCount).toBe(2)
})
})
describe('type-level registration — nodeTypes filter (replacement for S2.N8)', () => {
it('nodeTypes filter: nodeCreated fires only for matching comfyClass', () => {
const rt = createTestRuntime()
const received: string[] = []
rt.register({
name: 'bc01.type-scoped',
nodeTypes: ['KSampler'],
nodeCreated(h) {
received.push(h.type)
}
})
rt.mountNode(rt.addNode('KSampler'))
rt.mountNode(rt.addNode('CLIPTextEncode'))
expect(received).toEqual(['KSampler'])
})
it('omitting nodeTypes fires nodeCreated for every node type', () => {
const rt = createTestRuntime()
const received: string[] = []
rt.register({
name: 'bc01.global',
nodeCreated(h) {
received.push(h.type)
}
})
rt.mountNode(rt.addNode('KSampler'))
rt.mountNode(rt.addNode('CLIPTextEncode'))
expect(received).toEqual(['KSampler', 'CLIPTextEncode'])
})
it('type-scoped registration does not fire for unregistered node types', () => {
const rt = createTestRuntime()
let fired = false
rt.register({
name: 'bc01.no-fire',
nodeTypes: ['KSampler'],
nodeCreated() {
fired = true
}
})
rt.mountNode(rt.addNode('Note'))
expect(fired).toBe(false)
})
})
describe('extension firing order — D10b lexicographic', () => {
it('multiple extensions fire in lexicographic order by name for the same node', () => {
const rt = createTestRuntime()
const order: string[] = []
rt.register({
name: 'bc01.z-ext',
nodeCreated() {
order.push('z-ext')
}
})
rt.register({
name: 'bc01.a-ext',
nodeCreated() {
order.push('a-ext')
}
})
rt.register({
name: 'bc01.m-ext',
nodeCreated() {
order.push('m-ext')
}
})
rt.mountNode(rt.addNode('TestNode'))
expect(order).toEqual(['a-ext', 'm-ext', 'z-ext'])
})
})
describe('D12 reset-to-fresh on copy/paste', () => {
it('each mountNode call (new entityId) runs fresh nodeCreated — no shared state', () => {
const rt = createTestRuntime()
let setupCount = 0
rt.register({
name: 'bc01.fresh-copy',
nodeCreated() {
setupCount++
}
})
rt.mountNode(rt.addNode('TestNode')) // source
expect(setupCount).toBe(1)
rt.mountNode(rt.addNode('TestNode')) // paste → new entityId → new setup
expect(setupCount).toBe(2)
})
})
describe('VueNode mount timing invariant', () => {
it.todo(
// Phase B: requires VueNode mount simulation (BC.37 two-phase harness).
'nodeCreated fires before VueNode mounts; onNodeMounted deferred to Vue mount phase (Phase B)'
)
})
})

View File

@@ -1,278 +0,0 @@
// 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 defineNode({ onRemoved(handle) })
//
// These tests prove that v1 and v2 teardown produce identical outcomes on the
// same sequence of graph operations. "Identical" means:
// - cleanup fires the same number of times
// - cleanup fires AFTER the node is absent from the graph
// - cleanup closures can access the same mutable resources (interval, observer)
//
// Phase A harness note: v2 is modelled with effectScope + onScopeDispose (the
// primitive `onNodeRemoved` delegates to). v1 is modelled with a plain
// node.onRemoved assignment called explicitly after graph.remove(), matching
// how LiteGraph invokes the hook in production.
//
// I-TF.8.A2 — BC.02 migration wired assertions.
import { describe, expect, it, vi } from 'vitest'
import { effectScope, onScopeDispose } from 'vue'
import {
createHarnessWorld,
createMiniComfyApp,
loadEvidenceSnippet
} from '../harness'
// ── Shared helpers ────────────────────────────────────────────────────────────
function mountV2(setup: () => void) {
const scope = effectScope()
scope.run(setup)
return { unmount: () => scope.stop() }
}
// ── Wired assertions ──────────────────────────────────────────────────────────
describe('BC.02 migration — node lifecycle: teardown', () => {
describe('invocation parity (S2.N4)', () => {
it('v1 onRemoved and v2 onScopeDispose are both called exactly once for a single node removal', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
// v1 pattern
const v1Cleanup = vi.fn()
const entityId = app.graph.add({ type: 'LTXSparseTrack' })
const v1Node = { entityId, onRemoved: v1Cleanup }
// v2 pattern
const v2Cleanup = vi.fn()
const v2Mount = mountV2(() => {
onScopeDispose(v2Cleanup)
})
expect(v1Cleanup).not.toHaveBeenCalled()
expect(v2Cleanup).not.toHaveBeenCalled()
// Simulate removal
app.graph.remove(entityId)
v1Node.onRemoved() // LiteGraph calls this after graph removal
v2Mount.unmount() // service calls scope.stop() after graph removal
expect(v1Cleanup).toHaveBeenCalledOnce()
expect(v2Cleanup).toHaveBeenCalledOnce()
})
it('both v1 and v2 cleanup fire AFTER the node is absent from the graph', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
const entityId = app.graph.add({ type: 'KSampler' })
const observations: { v1NodeGone: boolean; v2NodeGone: boolean } = {
v1NodeGone: false,
v2NodeGone: false
}
const v1Node = {
entityId,
onRemoved() {
observations.v1NodeGone = world.findNode(entityId) === undefined
}
}
const v2Mount = mountV2(() => {
onScopeDispose(() => {
observations.v2NodeGone = world.findNode(entityId) === undefined
})
})
app.graph.remove(entityId) // removes from world
v1Node.onRemoved()
v2Mount.unmount()
expect(observations.v1NodeGone).toBe(true)
expect(observations.v2NodeGone).toBe(true)
})
it('v1 and v2 teardown are both called the correct number of times across multiple nodes', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
const v1Calls: string[] = []
const v2Calls: string[] = []
const nodes = ['NodeA', 'NodeB', 'NodeC'].map((type) => {
const entityId = app.graph.add({ type })
const v2 = mountV2(() => {
onScopeDispose(() => v2Calls.push(type))
})
return { type, entityId, onRemoved: () => v1Calls.push(type), v2 }
})
// Remove all in sequence
for (const node of nodes) {
app.graph.remove(node.id)
node.onRemoved()
node.v2.unmount()
}
expect(v1Calls).toEqual(['NodeA', 'NodeB', 'NodeC'])
expect(v2Calls).toEqual(['NodeA', 'NodeB', 'NodeC'])
})
})
describe('resource cleanup equivalence', () => {
it('interval cleared in v1 onRemoved is equivalently cleared in v2 onScopeDispose', () => {
vi.useFakeTimers()
const v1Ticks = vi.fn()
const v2Ticks = vi.fn()
let v2Handle: ReturnType<typeof globalThis.setInterval> | undefined
// v1 pattern: manual tracking
const v1Handle = setInterval(v1Ticks, 100)
const v1Node = {
onRemoved() {
clearInterval(v1Handle)
}
}
// v2 pattern: closure via onScopeDispose
const v2Mount = mountV2(() => {
v2Handle = setInterval(v2Ticks, 100)
onScopeDispose(() => clearInterval(v2Handle))
})
vi.advanceTimersByTime(250)
expect(v1Ticks).toHaveBeenCalledTimes(2)
expect(v2Ticks).toHaveBeenCalledTimes(2)
// Teardown both
v1Node.onRemoved()
v2Mount.unmount()
vi.advanceTimersByTime(500)
// Neither should tick after teardown
expect(v1Ticks).toHaveBeenCalledTimes(2)
expect(v2Ticks).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('observer.disconnect() pattern is equivalent between v1 and v2', () => {
const v1Observer = { disconnect: vi.fn() }
const v2Observer = { disconnect: vi.fn() }
// v1: manual disconnect in onRemoved
const v1Node = { onRemoved: () => v1Observer.disconnect() }
// v2: disconnect registered via onScopeDispose
const v2Mount = mountV2(() => {
onScopeDispose(() => v2Observer.disconnect())
})
expect(v1Observer.disconnect).not.toHaveBeenCalled()
expect(v2Observer.disconnect).not.toHaveBeenCalled()
v1Node.onRemoved()
v2Mount.unmount()
expect(v1Observer.disconnect).toHaveBeenCalledOnce()
expect(v2Observer.disconnect).toHaveBeenCalledOnce()
})
it('DOM element cleanup in v1 onRemoved is equivalent to onScopeDispose in v2', () => {
// Model DOM element as an object with a `remove()` method
const v1El = { remove: vi.fn(), isConnected: true }
const v2El = { remove: vi.fn(), isConnected: true }
const v1Node = {
onRemoved() {
v1El.remove()
v1El.isConnected = false
}
}
const v2Mount = mountV2(() => {
onScopeDispose(() => {
v2El.remove()
v2El.isConnected = false
})
})
v1Node.onRemoved()
v2Mount.unmount()
expect(v1El.remove).toHaveBeenCalledOnce()
expect(v1El.isConnected).toBe(false)
expect(v2El.remove).toHaveBeenCalledOnce()
expect(v2El.isConnected).toBe(false)
})
})
describe('graph clear coverage', () => {
it('both v1 and v2 teardown hooks are invoked for all nodes when world.clear() is called', () => {
const world = createHarnessWorld()
const app = createMiniComfyApp(world)
const v1Counts = { NodeA: 0, NodeB: 0 }
const v2Counts = { NodeA: 0, NodeB: 0 }
const nodeA = {
id: app.graph.add({ type: 'NodeA' }),
onRemoved: () => v1Counts.NodeA++,
v2: mountV2(() => {
onScopeDispose(() => v2Counts.NodeA++)
})
}
const nodeB = {
id: app.graph.add({ type: 'NodeB' }),
onRemoved: () => v1Counts.NodeB++,
v2: mountV2(() => {
onScopeDispose(() => v2Counts.NodeB++)
})
}
expect(world.allNodes()).toHaveLength(2)
// Simulate graph clear
world.clear()
nodeA.onRemoved()
nodeA.v2.unmount()
nodeB.onRemoved()
nodeB.v2.unmount()
expect(world.allNodes()).toHaveLength(0)
expect(v1Counts).toEqual({ NodeA: 1, NodeB: 1 })
expect(v2Counts).toEqual({ NodeA: 1, NodeB: 1 })
})
})
describe('S2.N4 — evidence excerpt shows real-world migration target', () => {
it('evidence excerpt content matches onRemoved v1 pattern', () => {
const snippet = loadEvidenceSnippet('S2.N4', 0)
// The real evidence should contain the v1 pattern the migration replaces
expect(snippet).toMatch(/onRemoved/i)
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.02 migration — node lifecycle: teardown [Phase B/C]', () => {
describe('end-to-end migration equivalence via eval sandbox', () => {
it.todo(
'v1 snippet from S2.N4 evidence, replayed via runV1(), produces the same cleanup count as a v2 port via runV2()'
)
it.todo(
'v1 onRemoved fires at the same position in the LiteGraph removal sequence as v2 scope.stop()'
)
it.todo(
'subgraph promotion (DOM move) does NOT fire v2 teardown, matching v1 behavior where onRemoved is not called on promotion'
)
})
})

View File

@@ -1,135 +0,0 @@
// 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',
id: app.graph.add({ type: 'LTXVideo' }),
onRemoved: cleanupFn
}
expect(world.findNode(node.id)).toBeDefined()
// Simulate the LiteGraph removal sequence (Phase A: explicit call).
app.graph.remove(node.id)
node.onRemoved()
expect(world.findNode(node.id)).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/C]', () => {
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

@@ -1,250 +0,0 @@
// 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: defineNode({ onRemoved(handle) { ... } })
//
// Phase A harness note: The full extension service (`extensionV2Service.ts`)
// cannot be imported here — it depends on `@/ecs/world` which doesn't exist
// until Phase B lands. The v2 teardown contract is implemented as
// `onNodeRemoved(fn)` → `onScopeDispose(fn)` inside a Vue EffectScope.
// These tests prove the EffectScope contract directly (the same primitive
// the service wraps), plus evidence-excerpt proof that the pattern surfaces.
//
// I-TF.8.A2 — BC.02 v2 wired assertions.
import { describe, expect, it, vi } from 'vitest'
import { effectScope, onScopeDispose } from 'vue'
import {
countEvidenceExcerpts,
createHarnessWorld,
loadEvidenceSnippet
} from '../harness'
// ── Helper: simulate the runtime's mount/unmount cycle ───────────────────────
// The real service does: scope = effectScope(); scope.run(() => nodeCreated(handle))
// Unmount: scope.stop() — which cascades all onScopeDispose callbacks.
function mountNode(setup: () => void) {
const scope = effectScope()
scope.run(setup)
return { unmount: () => scope.stop() }
}
// ── Wired assertions ─────────────────────────────────────────────────────────
describe('BC.02 v2 contract — node lifecycle: teardown', () => {
describe('onScopeDispose (onNodeRemoved primitive) — cleanup contract', () => {
it('cleanup registered via onScopeDispose fires exactly once when scope stops', () => {
const cleanup = vi.fn()
const { unmount } = mountNode(() => {
onScopeDispose(cleanup)
})
expect(cleanup).not.toHaveBeenCalled()
unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('cleanup does not fire a second time if unmount is called again', () => {
const cleanup = vi.fn()
const { unmount } = mountNode(() => {
onScopeDispose(cleanup)
})
unmount()
unmount() // second call is a no-op on a stopped scope
expect(cleanup).toHaveBeenCalledOnce()
})
it('multiple onScopeDispose registrations in one scope all fire on stop', () => {
const cbA = vi.fn()
const cbB = vi.fn()
const cbC = vi.fn()
const { unmount } = mountNode(() => {
onScopeDispose(cbA)
onScopeDispose(cbB)
onScopeDispose(cbC)
})
unmount()
expect(cbA).toHaveBeenCalledOnce()
expect(cbB).toHaveBeenCalledOnce()
expect(cbC).toHaveBeenCalledOnce()
})
it('each node gets its own scope: unmounting one does not fire another nodes cleanup', () => {
const cleanupA = vi.fn()
const cleanupB = vi.fn()
const nodeA = mountNode(() => {
onScopeDispose(cleanupA)
})
const nodeB = mountNode(() => {
onScopeDispose(cleanupB)
})
nodeA.unmount()
expect(cleanupA).toHaveBeenCalledOnce()
expect(cleanupB).not.toHaveBeenCalled()
nodeB.unmount()
expect(cleanupB).toHaveBeenCalledOnce()
})
it('cleanup fires for every node when world.clear() triggers unmount of all nodes', () => {
const world = createHarnessWorld()
// Mount 3 nodes, collect their unmount handles
const handles = [
mountNode(() => {
onScopeDispose(vi.fn())
}),
mountNode(() => {
onScopeDispose(vi.fn())
}),
mountNode(() => {
onScopeDispose(vi.fn())
})
]
world.addNode({ type: 'A' })
world.addNode({ type: 'B' })
world.addNode({ type: 'C' })
expect(world.allNodes()).toHaveLength(3)
// Simulate world.clear() + unmount all scopes
world.clear()
handles.forEach((h) => h.unmount())
expect(world.allNodes()).toHaveLength(0)
// All 3 scopes stopped without throwing — no assertion needed beyond no-throw
})
it('state captured in closure is still readable inside the cleanup callback', () => {
const observed: string[] = []
const { unmount } = mountNode(() => {
const nodeType = 'LTXSparseTrack'
onScopeDispose(() => {
observed.push(nodeType)
})
})
unmount()
expect(observed).toEqual(['LTXSparseTrack'])
})
it('onScopeDispose callbacks run in FIFO order (first registered fires first)', () => {
const order: string[] = []
const { unmount } = mountNode(() => {
onScopeDispose(() => order.push('first-registered'))
onScopeDispose(() => order.push('second-registered'))
onScopeDispose(() => order.push('third-registered'))
})
unmount()
// Vue runs onScopeDispose callbacks in registration order (FIFO)
expect(order).toEqual([
'first-registered',
'second-registered',
'third-registered'
])
})
it('an error in one cleanup callback stops subsequent callbacks (Vue behavior)', () => {
// IMPORTANT: This documents Vue's actual behavior — errors ARE NOT isolated.
// Extensions that need error isolation must wrap their cleanup in try/catch.
const cbA = vi.fn()
const cbB = vi.fn(() => {
throw new Error('cleanup B exploded')
})
const cbC = vi.fn()
const { unmount } = mountNode(() => {
onScopeDispose(cbA) // registered first, runs first
onScopeDispose(cbB) // registered second, throws
onScopeDispose(cbC) // registered third, never runs
})
// cbA runs first (success), cbB throws, cbC never runs
expect(() => unmount()).toThrow('cleanup B exploded')
expect(cbA).toHaveBeenCalledOnce() // ran first
expect(cbB).toHaveBeenCalledOnce() // threw
expect(cbC).not.toHaveBeenCalled() // never reached
})
})
describe('interval / observer teardown pattern', () => {
it('interval cleared in onScopeDispose does not fire after unmount', () => {
vi.useFakeTimers()
const intervalCallback = vi.fn()
let handle: ReturnType<typeof globalThis.setInterval> | undefined
const { unmount } = mountNode(() => {
handle = setInterval(intervalCallback, 100)
onScopeDispose(() => clearInterval(handle))
})
vi.advanceTimersByTime(250)
expect(intervalCallback).toHaveBeenCalledTimes(2)
unmount()
vi.advanceTimersByTime(500)
expect(intervalCallback).toHaveBeenCalledTimes(2) // no new calls after unmount
vi.useRealTimers()
})
it('observer.disconnect() called in onScopeDispose is invoked on unmount', () => {
const observer = { disconnect: vi.fn() }
const { unmount } = mountNode(() => {
onScopeDispose(() => observer.disconnect())
})
expect(observer.disconnect).not.toHaveBeenCalled()
unmount()
expect(observer.disconnect).toHaveBeenCalledOnce()
})
})
describe('S2.N4 — evidence excerpt', () => {
it('S2.N4 has at least one evidence excerpt in the snapshot', () => {
expect(countEvidenceExcerpts('S2.N4')).toBeGreaterThan(0)
})
it('S2.N4 evidence excerpt contains onRemoved fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N4', 0)
expect(snippet.length).toBeGreaterThan(0)
expect(snippet).toMatch(/onRemoved/i)
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.02 v2 contract — node lifecycle: teardown [Phase B/C]', () => {
describe('NodeExtensionOptions.nodeCreated — via defineNode', () => {
it.todo(
'onNodeRemoved() called inside nodeCreated fires when the node is unmounted by the service'
)
it.todo(
'NodeHandle passed to nodeCreated is the same handle accessible in the onNodeRemoved closure'
)
it.todo(
'NodeHandle.getState() is readable inside the onNodeRemoved closure (state not yet cleared)'
)
})
describe('auto-disposal ordering', () => {
it.todo(
'handle-registered DOM widgets are removed from the DOM before onScopeDispose callbacks fire'
)
it.todo(
'scope registry entry is absent after unmountExtensionsForNode returns'
)
})
})

View File

@@ -1,190 +0,0 @@
// Category: BC.03 — Node lifecycle: hydration from saved workflows
// DB cross-ref: S1.H1, S2.N7
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 node.onConfigure / beforeRegisterNodeDef → v2 defineNode({ loadedGraphNode(handle) })
//
// Key rename: the v1 surface is `node.onConfigure = function(data) { ... }`
// patched prototype-level. The v2 replacement is `loadedGraphNode(handle)` in
// `defineNode`. The argument shape changes: v1 receives the raw
// serialized node object (data); v2 receives a typed NodeHandle (widget values
// already applied by the runtime before the hook fires).
import { describe, expect, it, vi } from 'vitest'
import {
countEvidenceExcerpts,
createHarnessWorld,
loadEvidenceSnippet
} from '../harness'
// ── Wired migration tests (Phase A) ─────────────────────────────────────────
describe('BC.03 migration — node lifecycle: hydration from saved workflows', () => {
describe('invocation parity (S2.N7)', () => {
it('v1 onConfigure and v2 loadedGraphNode are each called exactly once per node during workflow load', () => {
const world = createHarnessWorld()
const v1Calls: string[] = []
const v2Calls: string[] = []
// v1 model: extension patches onConfigure during beforeRegisterNodeDef.
// We model the patched-prototype invocation as a direct call here.
const v1Ext = {
beforeRegisterNodeDef(_nodeType: string) {
// Prototype patch: every instance of this type gets onConfigure.
return {
onConfigure: (data: { type: string }) => v1Calls.push(data.type)
}
}
}
// v2 model: loadedGraphNode(handle) per lifecycle.ts:98
const v2Ext = {
name: 'test.hydration-migration',
loadedGraphNode: vi.fn((handle: { type: string }) =>
v2Calls.push(handle.type)
)
}
// Simulate loading three nodes from a workflow.
const nodeTypes = ['KSampler', 'CLIPTextEncode', 'VAEDecode']
for (const type of nodeTypes) {
const entityId = world.addNode({ type })
const record = world.findNode(entityId)!
// v1: runtime calls node.onConfigure(serializedData) after configure().
const patchedMethods = v1Ext.beforeRegisterNodeDef(type)
patchedMethods.onConfigure({ type })
// v2: runtime calls loadedGraphNode(handle).
v2Ext.loadedGraphNode({ type: record.type })
}
expect(v1Calls).toHaveLength(3)
expect(v2Calls).toHaveLength(3)
expect(v1Calls).toEqual(v2Calls)
})
it('the property data accessible in v2 loadedGraphNode contains the same keys as v1 onConfigure data', () => {
const world = createHarnessWorld()
// v1: data = raw serialized node object with properties field.
const v1DataSeen: Record<string, unknown> = {}
const v1OnConfigure = (data: { properties: Record<string, unknown> }) => {
Object.assign(v1DataSeen, data.properties)
}
// v2: handle.properties — same bag, typed access.
const v2PropertiesSeen: Record<string, unknown> = {}
const v2LoadedGraphNode = (handle: {
properties: Record<string, unknown>
}) => {
Object.assign(v2PropertiesSeen, handle.properties)
}
const savedProperties = { custom_label: 'upscaler', strength: 0.75 }
const entityId = world.addNode({
type: 'KSampler',
properties: savedProperties
})
const record = world.findNode(entityId)!
v1OnConfigure({ properties: record.properties })
v2LoadedGraphNode({ properties: record.properties })
expect(v1DataSeen).toEqual(v2PropertiesSeen)
expect(v2PropertiesSeen.custom_label).toBe('upscaler')
expect(v2PropertiesSeen.strength).toBe(0.75)
})
})
describe('type-scoped filtering parity (S1.H1)', () => {
it('v1 beforeRegisterNodeDef guard and v2 nodeTypes:[] produce the same filtered invocation set', () => {
const world = createHarnessWorld()
const v1HookTargets: string[] = []
const v2HookTargets: string[] = []
// v1: guard pattern — beforeRegisterNodeDef checks nodeType.
const v1GuardFn = (nodeTypeName: string) => {
if (nodeTypeName === 'KSampler') {
return {
onConfigure: (data: { type: string }) =>
v1HookTargets.push(data.type)
}
}
return null
}
// v2: type-scoped loadedGraphNode.
const v2Ext = {
name: 'test.type-scope-parity',
nodeTypes: ['KSampler'],
loadedGraphNode: (handle: { type: string }) =>
v2HookTargets.push(handle.type)
}
const allTypes = ['KSampler', 'CLIPTextEncode', 'VAEDecode', 'KSampler']
for (const type of allTypes) {
const entityId = world.addNode({ type })
const record = world.findNode(entityId)!
// v1 dispatch.
const patched = v1GuardFn(type)
if (patched) patched.onConfigure({ type })
// v2 dispatch.
if (v2Ext.nodeTypes.includes(type)) {
v2Ext.loadedGraphNode({ type: record.type })
}
}
// Both should only have fired for 'KSampler' (twice).
expect(v1HookTargets).toEqual(['KSampler', 'KSampler'])
expect(v2HookTargets).toEqual(['KSampler', 'KSampler'])
expect(v1HookTargets).toEqual(v2HookTargets)
})
})
describe('fresh-creation exclusion invariant', () => {
it('neither v1 onConfigure nor v2 loadedGraphNode fires for a freshly created node', () => {
// This invariant is load-vs-create gating — the same truth on both sides.
const v1ConfigureFn = vi.fn()
const v2LoadedFn = vi.fn()
// Simulate fresh creation: runtime does NOT call onConfigure / loadedGraphNode.
// (Only nodeCreated / onNodeCreated fire for fresh nodes.)
void createHarnessWorld().addNode({ type: 'KSampler' })
// Neither function called — fresh creation path.
expect(v1ConfigureFn).not.toHaveBeenCalled()
expect(v2LoadedFn).not.toHaveBeenCalled()
})
})
describe('evidence parity (S1.H1, S2.N7)', () => {
it('S1.H1 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S1.H1')).toBeGreaterThan(0)
})
it('S2.N7 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N7')).toBeGreaterThan(0)
})
it('S2.N7 excerpt uses onConfigure — the v1 hydration surface being replaced', () => {
const snippet = loadEvidenceSnippet('S2.N7', 0)
expect(snippet).toMatch(/onConfigure/i)
})
})
})
// ── Phase B stubs — need real configure() lifecycle + LoadedFromWorkflow tag ─
describe('BC.03 migration — hydration [Phase B/C]', () => {
it.todo(
'v2 loadedGraphNode fires at the same point in the LiteGraph configure() lifecycle as v1 onConfigure'
)
it.todo(
'custom properties written to data in v1 onConfigure are accessible via handle.properties in v2 loadedGraphNode without any migration shim'
)
})

View File

@@ -1,236 +0,0 @@
// 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 { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
createMiniComfyApp,
countEvidenceExcerpts,
loadEvidenceSnippet,
runV1
} from '../harness'
interface SerializedNodeData {
widgets_values?: unknown[]
properties?: Record<string, unknown>
[key: string]: unknown
}
describe('BC.03 v1 contract — node lifecycle: hydration from saved workflows', () => {
describe('S2.N7 — evidence excerpts', () => {
it('S2.N7 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N7')).toBeGreaterThan(0)
})
it('S2.N7 evidence snippet contains onConfigure fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N7', 0)
expect(snippet).toMatch(/onConfigure/i)
})
it('S2.N7 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N7', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S1.H1 — evidence excerpts', () => {
it('S1.H1 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S1.H1')).toBeGreaterThan(0)
})
it('S1.H1 evidence snippet contains beforeRegisterNodeDef fingerprint', () => {
const count = countEvidenceExcerpts('S1.H1')
let found = false
for (let i = 0; i < count; i++) {
if (/beforeRegisterNodeDef/i.test(loadEvidenceSnippet('S1.H1', i))) {
found = true
break
}
}
expect(
found,
'Expected at least one S1.H1 excerpt with beforeRegisterNodeDef fingerprint'
).toBe(true)
})
it('S1.H1 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S1.H1', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N7 — node.onConfigure (synthetic)', () => {
it('onConfigure callback receives the raw serialized data object', () => {
const received: SerializedNodeData[] = []
const node = {
onConfigure: vi.fn((data: SerializedNodeData) => received.push(data))
}
const serializedData: SerializedNodeData = {
widgets_values: [42],
properties: { custom_label: 'upscaler' }
}
node.onConfigure(serializedData)
expect(node.onConfigure).toHaveBeenCalledOnce()
expect(received[0]).toBe(serializedData)
})
it('widget values in data.widgets_values are accessible inside the callback', () => {
let capturedWidgetsValues: unknown[] | undefined
const node = {
onConfigure(data: SerializedNodeData) {
capturedWidgetsValues = data.widgets_values as unknown[]
}
}
node.onConfigure({
widgets_values: [42],
properties: { custom_label: 'upscaler' }
})
expect(capturedWidgetsValues).toEqual([42])
})
it('custom properties in data.properties are accessible inside the callback', () => {
let capturedLabel: unknown
const node = {
onConfigure(data: SerializedNodeData) {
capturedLabel = data.properties?.custom_label
}
}
node.onConfigure({
widgets_values: [42],
properties: { custom_label: 'upscaler' }
})
expect(capturedLabel).toBe('upscaler')
})
it('onConfigure is NOT called on fresh creation (only on load)', () => {
const onConfigure = vi.fn()
// A freshly created node never has onConfigure invoked by the runtime
// — we assert no invocations occurred without any explicit call.
expect(onConfigure).not.toHaveBeenCalled()
})
describe('fires during actual LiteGraph graph.configure()', () => {
// The v1 contract is: when graph.configure(serializedGraph) is called,
// each restored LGraphNode has its `onConfigure(info)` invoked with the
// raw serialized node payload — the de-facto hydration hook used by
// 51 consumers per W2F-1 (S2.N7 RED tier).
//
// We register a custom LGraphNode subclass whose prototype has an
// onConfigure spy, serialize a graph that contains an instance of it,
// then feed the serialized payload back through `graph.configure()`
// and assert the spy fires with the per-node info object.
const registeredTypes: string[] = []
beforeEach(() => {
// LGraphNode constructor exercises LGraphNodeProperties which
// touches Pinia-backed stores in some code paths; activate a
// testing pinia to match the canonical LiteGraph test harness
// (see src/lib/litegraph/src/LGraph.repointAncestorPromotions.test.ts).
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
for (const t of registeredTypes) {
LiteGraph.unregisterNodeType(t)
}
registeredTypes.length = 0
})
function registerSpyNode(spy: (info: unknown) => void): string {
const type = `bc03/onconfigure-${Math.random().toString(36).slice(2)}`
class SpyNode extends LGraphNode {
constructor() {
super('SpyNode', type)
}
override onConfigure(info: unknown): void {
spy(info)
}
}
LiteGraph.registerNodeType(type, SpyNode)
registeredTypes.push(type)
return type
}
it('invokes onConfigure on each restored node with the serialized info object', () => {
const spy = vi.fn()
const type = registerSpyNode(spy)
// Seed graph with one node of our spy type.
const seedGraph = new LGraph()
const seedNode = LiteGraph.createNode(type)
expect(seedNode).not.toBeNull()
seedGraph.add(seedNode!)
const serialized = seedGraph.serialize()
// The spy was wired on the prototype; the seed instance's own
// .configure() was never called (we used .add(), not .configure()).
// Confirm hydration is what drives the call, not creation.
expect(spy).not.toHaveBeenCalled()
// Hydrate a fresh graph from the serialized payload.
const targetGraph = new LGraph()
targetGraph.configure(serialized)
expect(spy).toHaveBeenCalledTimes(1)
const info = spy.mock.calls[0][0] as Record<string, unknown>
expect(info.type).toBe(type)
})
})
it.todo(
'LoadedFromWorkflow ECS tag — needs world.dispatch (Phase B blocked, see I-TF.8.J1)'
)
})
describe('S1.H1 — beforeRegisterNodeDef hydration guard (synthetic)', () => {
it('prototype-level onConfigure injected in beforeRegisterNodeDef fires for all instances', () => {
const calls: unknown[] = []
const proto: Record<string, unknown> = {}
// Simulate beforeRegisterNodeDef injecting onConfigure on the prototype
function beforeRegisterNodeDef(nodeType: {
prototype: Record<string, unknown>
}) {
nodeType.prototype.onConfigure = function (data: SerializedNodeData) {
calls.push(data)
}
}
beforeRegisterNodeDef({ prototype: proto })
const instanceA = Object.create(proto) as {
onConfigure: (d: SerializedNodeData) => void
}
const instanceB = Object.create(proto) as {
onConfigure: (d: SerializedNodeData) => void
}
const dataA: SerializedNodeData = { widgets_values: [1] }
const dataB: SerializedNodeData = { widgets_values: [2] }
instanceA.onConfigure(dataA)
instanceB.onConfigure(dataB)
expect(calls).toHaveLength(2)
expect(calls[0]).toBe(dataA)
expect(calls[1]).toBe(dataB)
})
})
})

View File

@@ -1,230 +0,0 @@
// Category: BC.03 — Node lifecycle: hydration from saved workflows
// DB cross-ref: S1.H1, S2.N7
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: defineNode({ loadedGraphNode(handle) { ... } })
//
// Phase A harness: loadedGraphNode(handle) is called explicitly after addNode()
// with a `fromWorkflow: true` flag to distinguish hydration from fresh creation.
// The real reactive dispatch (watch(queryAll) + LoadedFromWorkflow tag) lands in
// Phase B (I-SR.3.B4). Tests that need real LiteGraph configure() wiring are
// marked todo(Phase B).
import { describe, expect, it, vi } from 'vitest'
import {
countEvidenceExcerpts,
createHarnessWorld,
loadEvidenceSnippet
} from '../harness'
// ── Wired tests (Phase A) ────────────────────────────────────────────────────
// These pass today. They prove:
// (a) loadedGraphNode hook shape: receives a NodeHandle-shaped object
// (b) widget values are already present when the hook fires
// (c) exactly one of loadedGraphNode / nodeCreated fires per entity
// (d) type-filter (nodeTypes:[]) excludes non-matching nodes
// (e) evidence excerpts exist for S2.N7
describe('BC.03 v2 contract — node lifecycle: hydration from saved workflows', () => {
describe('loadedGraphNode(handle) — hook shape and invocation', () => {
it('loadedGraphNode receives a handle-shaped object with type and entityId', () => {
const world = createHarnessWorld()
const capturedHandles: unknown[] = []
const entityId = world.addNode({
type: 'KSampler',
properties: { seed: 42 }
})
const record = world.findNode(entityId)!
// Phase A: simulate the v2 dispatch by calling loadedGraphNode directly
// with a handle constructed from the world record.
const handle = {
type: record.type,
comfyClass: record.comfyClass,
id: record.id,
title: record.title,
properties: record.properties
}
const ext = {
name: 'test.hydration',
loadedGraphNode: vi.fn((h: unknown) => capturedHandles.push(h))
}
// Simulate runtime calling loadedGraphNode(handle) for a workflow-loaded node.
ext.loadedGraphNode(handle)
expect(ext.loadedGraphNode).toHaveBeenCalledOnce()
expect(capturedHandles).toHaveLength(1)
const received = capturedHandles[0] as typeof handle
expect(received.type).toBe('KSampler')
expect(received.id).toBe(entityId)
})
it('widget values are present on the handle when loadedGraphNode fires', () => {
const world = createHarnessWorld()
// Harness models "widget values already populated" as properties on the record.
const entityId = world.addNode({
type: 'KSampler',
properties: { seed: 42, steps: 20, cfg: 7.5 }
})
const record = world.findNode(entityId)!
const seenProperties: Record<string, unknown> = {}
const ext = {
name: 'test.hydration-values',
loadedGraphNode(handle: { properties: Record<string, unknown> }) {
Object.assign(seenProperties, handle.properties)
}
}
ext.loadedGraphNode({ properties: record.properties })
expect(seenProperties.seed).toBe(42)
expect(seenProperties.steps).toBe(20)
expect(seenProperties.cfg).toBe(7.5)
})
it('loadedGraphNode is NOT called for a freshly created node', () => {
// Model: fresh creation → nodeCreated fires; loadedGraphNode does NOT fire.
const loadedFn = vi.fn()
const createdFn = vi.fn()
const ext = {
name: 'test.exclusion',
nodeCreated: createdFn,
loadedGraphNode: loadedFn
}
const world = createHarnessWorld()
const entityId = world.addNode({ type: 'KSampler' })
const record = world.findNode(entityId)!
// Simulate fresh creation: only nodeCreated fires.
ext.nodeCreated({ type: record.type, id: record.id })
expect(createdFn).toHaveBeenCalledOnce()
expect(loadedFn).not.toHaveBeenCalled()
})
it('nodeCreated is NOT called for a workflow-loaded node', () => {
// Model: workflow load → loadedGraphNode fires; nodeCreated does NOT fire.
const loadedFn = vi.fn()
const createdFn = vi.fn()
const ext = {
name: 'test.exclusion-loaded',
nodeCreated: createdFn,
loadedGraphNode: loadedFn
}
const world = createHarnessWorld()
const entityId = world.addNode({ type: 'CLIPTextEncode' })
const record = world.findNode(entityId)!
// Simulate workflow load: only loadedGraphNode fires.
ext.loadedGraphNode({ type: record.type, id: record.id })
expect(loadedFn).toHaveBeenCalledOnce()
expect(createdFn).not.toHaveBeenCalled()
})
})
describe('ordering — loadedGraphNode fires after the node is in the World', () => {
it('the node is already present in the World when loadedGraphNode fires', () => {
const world = createHarnessWorld()
let nodeFoundDuringHook = false
const entityId = world.addNode({ type: 'VAEDecode' })
const ext = {
name: 'test.ordering',
loadedGraphNode(handle: { id: number }) {
nodeFoundDuringHook = world.findNode(handle.id) !== undefined
}
}
ext.loadedGraphNode({ entityId })
expect(nodeFoundDuringHook).toBe(true)
})
})
describe('type-scoped filtering (nodeTypes:[])', () => {
it('loadedGraphNode does not fire for non-matching node types when nodeTypes is set', () => {
const loadedFn = vi.fn()
const ext = {
name: 'test.type-filter',
nodeTypes: ['KSampler'],
loadedGraphNode: loadedFn
}
const world = createHarnessWorld()
world.addNode({ type: 'CLIPTextEncode' })
world.addNode({ type: 'VAEDecode' })
const kSamplerId = world.addNode({ type: 'KSampler' })
// Simulate filtered dispatch: runtime only calls loadedGraphNode for matching types.
for (const record of world.allNodes()) {
if (ext.nodeTypes.includes(record.type)) {
ext.loadedGraphNode({ type: record.type, id: record.id })
}
}
expect(loadedFn).toHaveBeenCalledOnce()
const handle = loadedFn.mock.calls[0][0] as { id: number }
expect(handle.id).toBe(kSamplerId)
})
it('loadedGraphNode fires for every workflow-loaded node when nodeTypes is omitted', () => {
const loadedFn = vi.fn()
const ext = {
name: 'test.no-filter',
// nodeTypes not set → matches all
loadedGraphNode: loadedFn
}
const world = createHarnessWorld()
world.addNode({ type: 'KSampler' })
world.addNode({ type: 'CLIPTextEncode' })
world.addNode({ type: 'VAEDecode' })
// Simulate unfiltered dispatch.
for (const record of world.allNodes()) {
ext.loadedGraphNode({ type: record.type, id: record.id })
}
expect(loadedFn).toHaveBeenCalledTimes(3)
})
})
describe('S2.N7 evidence excerpts', () => {
it('S2.N7 has at least one evidence excerpt in the snapshot', () => {
expect(countEvidenceExcerpts('S2.N7')).toBeGreaterThan(0)
})
it('S2.N7 excerpt contains onConfigure fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N7', 0)
expect(snippet.length).toBeGreaterThan(0)
expect(snippet).toMatch(/onConfigure/i)
})
})
})
// ── Phase B stubs — need LoadedFromWorkflow ECS tag + real configure() wiring ─
describe('BC.03 v2 contract — node lifecycle: hydration [Phase B/C]', () => {
it.todo(
'loadedGraphNode fires (not nodeCreated) when a node enters the World with the LoadedFromWorkflow ECS tag component present'
)
it.todo(
'state written to extensionState inside loadedGraphNode is readable in all subsequent hook calls for that entity'
)
it.todo(
'loadedGraphNode is not called a second time if graph.configure() is called again on the same entity (idempotent)'
)
})

View File

@@ -1,109 +0,0 @@
// Category: BC.04 — Node interaction: pointer, selection, resize
// DB cross-ref: S2.N10, S2.N17, S2.N19
// blast_radius: 4.95 — compat-floor ≥ 2.0
// Migration: v1 prototype assignments → v2 handle.on() subscriptions
//
// v1 pattern (S2.N19):
// nodeType.prototype.onResize = function([w, h]) { relayout(w, h) }
// v2 pattern:
// node.on('sizeChanged', (e) => relayout(e.size.width, e.size.height))
//
// sizeChanged is the only BC.04 event testable in Phase A.
// mouseDown + selected/deselected migration tests are Phase B (API not yet present).
import { describe, expect, it, vi } from 'vitest'
import type { NodeSizeChangedEvent, Size } from '@/extension-api/node'
import type { Unsubscribe } from '@/extension-api/events'
// ── Shared mock ───────────────────────────────────────────────────────────────
interface MockNode {
on(
event: 'sizeChanged',
handler: (e: NodeSizeChangedEvent) => void
): Unsubscribe
_emitSizeChanged(size: Size): void
}
function createMockNode(): MockNode {
const listeners: Array<(e: NodeSizeChangedEvent) => void> = []
return {
on(
_event: 'sizeChanged',
handler: (e: NodeSizeChangedEvent) => void
): Unsubscribe {
listeners.push(handler)
return () => {
const idx = listeners.indexOf(handler)
if (idx !== -1) listeners.splice(idx, 1)
}
},
_emitSizeChanged(size: Size) {
const event: NodeSizeChangedEvent = { size }
for (const fn of [...listeners]) fn(event)
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.04 migration — node interaction: pointer, selection, resize', () => {
describe('resize parity: v1 onResize([w,h]) ↔ v2 on("sizeChanged", { size }) (S2.N19)', () => {
it('v2 sizeChanged handler receives same dimensions that v1 onResize received', () => {
const node = createMockNode()
const v2Sizes: Size[] = []
node.on('sizeChanged', (e) => v2Sizes.push(e.size))
// Simulate the same resize LiteGraph called node.onResize([300, 200]) for
node._emitSizeChanged([300, 200])
expect(v2Sizes).toEqual([[300, 200]])
})
it('multiple resize events all reach the v2 handler (parity with repeated v1 onResize calls)', () => {
const node = createMockNode()
const widths: number[] = []
node.on('sizeChanged', (e) => widths.push(e.size[0]))
node._emitSizeChanged([100, 50])
node._emitSizeChanged([200, 80])
node._emitSizeChanged([300, 120])
expect(widths).toEqual([100, 200, 300])
})
it.todo(
'[Phase B/C] computeSize overrides that triggered v1 onResize still trigger v2 sizeChanged'
)
})
describe('mousedown parity (S2.N10) — Phase B', () => {
it.todo(
'[Phase B/C] v1 node.onMouseDown and v2 handle.on("mouseDown") both fire for the same pointer-down event'
)
it.todo(
'[Phase B/C] local coordinates in v1 onMouseDown(event, [x,y]) match v2 event.x / event.y'
)
it.todo(
'[Phase B/C] propagation-stop: v1 return true ≡ v2 event.stopPropagation()'
)
})
describe('selection parity (S2.N17) — Phase B', () => {
it.todo(
'[Phase B/C] v1 node.onSelected and v2 handle.on("selected") both fire when node is selected'
)
it.todo(
'[Phase B/C] v2 introduces explicit deselected event; migration must add deselected handler for cleanup that relied on onSelected re-fire in v1'
)
})
describe('listener lifetime parity', () => {
it('v2 unsub() gives explicit cleanup control (v1 prototype assignments had no built-in cleanup)', () => {
const node = createMockNode()
const handler = vi.fn()
const unsub = node.on('sizeChanged', handler)
unsub()
node._emitSizeChanged([100, 50])
expect(handler).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,165 +0,0 @@
// 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, expect, it, vi } from 'vitest'
import {
createMiniComfyApp,
countEvidenceExcerpts,
loadEvidenceSnippet,
runV1
} from '../harness'
describe('BC.04 v1 contract — node interaction: pointer, selection, resize', () => {
describe('S2.N10 — evidence excerpts', () => {
it('S2.N10 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N10')).toBeGreaterThan(0)
})
it('S2.N10 evidence snippet contains onMouseDown fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N10', 0)
expect(snippet).toMatch(/onMouseDown/i)
})
it('S2.N10 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N10', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N17 — evidence excerpts', () => {
it('S2.N17 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N17')).toBeGreaterThan(0)
})
it('S2.N17 evidence snippet contains onSelected fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N17', 0)
expect(snippet).toMatch(/onSelected/i)
})
it('S2.N17 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N17', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N19 — evidence excerpts', () => {
it('S2.N19 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N19')).toBeGreaterThan(0)
})
it('S2.N19 evidence snippet contains onResize fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N19', 0)
expect(snippet).toMatch(/onResize/i)
})
it('S2.N19 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N19', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N10 — node.onMouseDown (synthetic)', () => {
it('callback receives (event, [x, y]) — synthetic: call with a fake MouseEvent stub and local coords', () => {
const received: unknown[] = []
const node = {
onMouseDown: vi.fn((event: unknown, pos: unknown) => {
received.push(event, pos)
})
}
const fakeEvent = { type: 'mousedown', button: 0 }
const localCoords: [number, number] = [15, 30]
node.onMouseDown(fakeEvent, localCoords)
expect(node.onMouseDown).toHaveBeenCalledOnce()
expect(received[0]).toBe(fakeEvent)
expect(received[1]).toEqual([15, 30])
})
it('returning true from onMouseDown signals propagation stop', () => {
const node = {
onMouseDown(_event: unknown, _pos: unknown): boolean {
return true
}
}
const fakeEvent = { type: 'mousedown', button: 0 }
const result = node.onMouseDown(fakeEvent, [0, 0])
expect(result).toBe(true)
})
it('NOT called when pointer is outside bounds — model: guard fn only calls if within bounds', () => {
const handler = vi.fn()
const node = { width: 100, height: 60, onMouseDown: handler }
function dispatchMouseDown(
target: typeof node,
event: unknown,
localPos: [number, number]
) {
const [x, y] = localPos
if (x >= 0 && x <= target.width && y >= 0 && y <= target.height) {
target.onMouseDown(event, localPos)
}
}
const fakeEvent = { type: 'mousedown', button: 0 }
dispatchMouseDown(node, fakeEvent, [150, 10]) // outside x
expect(handler).not.toHaveBeenCalled()
})
it.todo('canvas rendering tests (need LiteGraph canvas)')
it.todo('real pointer events (need LiteGraph canvas)')
})
describe('S2.N17 — node.onSelected (synthetic)', () => {
it('onSelected called when node transitions to selected state', () => {
const onSelected = vi.fn()
const node = { id: 1, selected: false, onSelected }
node.selected = true
node.onSelected()
expect(onSelected).toHaveBeenCalledOnce()
})
it('not called when a different node is selected — model: dispatch to specific node only', () => {
const onSelectedA = vi.fn()
const onSelectedB = vi.fn()
const nodeA = { id: 1, onSelected: onSelectedA }
const nodeB = { id: 2, onSelected: onSelectedB }
// Simulate the graph selecting only nodeB
function selectNode(target: typeof nodeA) {
target.onSelected()
}
selectNode(nodeB)
expect(onSelectedB).toHaveBeenCalledOnce()
expect(onSelectedA).not.toHaveBeenCalled()
})
})
describe('S2.N19 — node.onResize (synthetic)', () => {
it('onResize receives new [width, height]', () => {
const received: unknown[] = []
const node = {
onResize: vi.fn((size: [number, number]) => received.push(size))
}
node.onResize([300, 200])
expect(node.onResize).toHaveBeenCalledOnce()
expect(received[0]).toEqual([300, 200])
})
})
})

View File

@@ -1,233 +0,0 @@
// Category: BC.04 — Node interaction: pointer, selection, resize
// DB cross-ref: S2.N10, S2.N17, S2.N19
// blast_radius: 4.95 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
//
// API surface status (Phase A):
// sizeChanged — PRESENT in NodeHandle (node.ts:501)
// positionChanged — PRESENT in NodeHandle (node.ts:490)
// mouseDown — NOT YET (Phase B canvas event)
// selected/deselected — NOT YET (Phase B ECS event)
//
// Harness: inline MockNodeHandle — no ECS world needed for type-shape + event tests.
import { describe, expect, it, vi } from 'vitest'
import type {
NodeSizeChangedEvent,
NodePositionChangedEvent
} from '@/extension-api/node'
import type { Unsubscribe } from '@/extension-api/events'
// ── Minimal mock ──────────────────────────────────────────────────────────────
interface NodeEventEmitter {
on(
event: 'sizeChanged',
handler: (e: NodeSizeChangedEvent) => void
): Unsubscribe
on(
event: 'positionChanged',
handler: (e: NodePositionChangedEvent) => void
): Unsubscribe
_emitSizeChanged(size: { width: number; height: number }): void
_emitPositionChanged(position: { x: number; y: number }): void
}
function createMockNode(): NodeEventEmitter {
const sizeListeners: Array<(e: NodeSizeChangedEvent) => void> = []
const positionListeners: Array<(e: NodePositionChangedEvent) => void> = []
return {
on(event: string, handler: (e: unknown) => void): Unsubscribe {
if (event === 'sizeChanged') {
sizeListeners.push(handler as (e: NodeSizeChangedEvent) => void)
return () => {
const idx = sizeListeners.indexOf(
handler as (e: NodeSizeChangedEvent) => void
)
if (idx !== -1) sizeListeners.splice(idx, 1)
}
} else if (event === 'positionChanged') {
positionListeners.push(handler as (e: NodePositionChangedEvent) => void)
return () => {
const idx = positionListeners.indexOf(
handler as (e: NodePositionChangedEvent) => void
)
if (idx !== -1) positionListeners.splice(idx, 1)
}
}
throw new Error(`Unknown event: ${event}`)
},
_emitSizeChanged(size) {
const event: NodeSizeChangedEvent = { size }
for (const fn of [...sizeListeners]) fn(event)
},
_emitPositionChanged(position) {
const event: NodePositionChangedEvent = { position }
for (const fn of [...positionListeners]) fn(event)
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.04 v2 contract — node interaction: pointer, selection, resize', () => {
describe("on('sizeChanged') — resize feedback (S2.N19)", () => {
it('fires with { size: { width, height } } when node dimensions change', () => {
const node = createMockNode()
const handler = vi.fn<[NodeSizeChangedEvent], void>()
node.on('sizeChanged', handler)
node._emitSizeChanged({ width: 300, height: 200 })
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith({
size: { width: 300, height: 200 }
})
})
it('fires again on subsequent resize; each call gets the latest size', () => {
const node = createMockNode()
const sizes: { width: number; height: number }[] = []
node.on('sizeChanged', (e) => sizes.push(e.size))
node._emitSizeChanged({ width: 100, height: 50 })
node._emitSizeChanged({ width: 200, height: 80 })
expect(sizes).toEqual([
{ width: 100, height: 50 },
{ width: 200, height: 80 }
])
})
it('unsubscribe stops future firings', () => {
const node = createMockNode()
const handler = vi.fn()
const unsub = node.on('sizeChanged', handler)
unsub()
node._emitSizeChanged({ width: 300, height: 200 })
expect(handler).not.toHaveBeenCalled()
})
it('multiple listeners all receive the event independently', () => {
const node = createMockNode()
const a = vi.fn(),
b = vi.fn()
node.on('sizeChanged', a)
node.on('sizeChanged', b)
node._emitSizeChanged({ width: 150, height: 120 })
expect(a).toHaveBeenCalledOnce()
expect(b).toHaveBeenCalledOnce()
})
it('unsubscribing one listener does not affect others', () => {
const node = createMockNode()
const a = vi.fn(),
b = vi.fn()
const unsubA = node.on('sizeChanged', a)
node.on('sizeChanged', b)
unsubA()
node._emitSizeChanged({ width: 200, height: 100 })
expect(a).not.toHaveBeenCalled()
expect(b).toHaveBeenCalledOnce()
})
})
describe("on('positionChanged') — move feedback (S2.N17)", () => {
it('fires with { position: { x, y } } when node position changes', () => {
const node = createMockNode()
const handler = vi.fn<[NodePositionChangedEvent], void>()
node.on('positionChanged', handler)
node._emitPositionChanged({ x: 100, y: 200 })
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith({
position: { x: 100, y: 200 }
})
})
it('fires again on subsequent move; each call gets the latest position', () => {
const node = createMockNode()
const positions: { x: number; y: number }[] = []
node.on('positionChanged', (e) => positions.push(e.position))
node._emitPositionChanged({ x: 0, y: 0 })
node._emitPositionChanged({ x: 50, y: 100 })
node._emitPositionChanged({ x: 200, y: 300 })
expect(positions).toEqual([
{ x: 0, y: 0 },
{ x: 50, y: 100 },
{ x: 200, y: 300 }
])
})
it('unsubscribe stops future firings', () => {
const node = createMockNode()
const handler = vi.fn()
const unsub = node.on('positionChanged', handler)
unsub()
node._emitPositionChanged({ x: 100, y: 100 })
expect(handler).not.toHaveBeenCalled()
})
it('multiple listeners all receive the event independently', () => {
const node = createMockNode()
const a = vi.fn(),
b = vi.fn()
node.on('positionChanged', a)
node.on('positionChanged', b)
node._emitPositionChanged({ x: 50, y: 75 })
expect(a).toHaveBeenCalledOnce()
expect(b).toHaveBeenCalledOnce()
})
it('unsubscribing one listener does not affect others', () => {
const node = createMockNode()
const a = vi.fn(),
b = vi.fn()
const unsubA = node.on('positionChanged', a)
node.on('positionChanged', b)
unsubA()
node._emitPositionChanged({ x: 100, y: 100 })
expect(a).not.toHaveBeenCalled()
expect(b).toHaveBeenCalledOnce()
})
it('sizeChanged and positionChanged are independent events', () => {
const node = createMockNode()
const sizeFn = vi.fn()
const posFn = vi.fn()
node.on('sizeChanged', sizeFn)
node.on('positionChanged', posFn)
node._emitSizeChanged({ width: 100, height: 50 })
expect(sizeFn).toHaveBeenCalledOnce()
expect(posFn).not.toHaveBeenCalled()
node._emitPositionChanged({ x: 10, y: 20 })
expect(sizeFn).toHaveBeenCalledOnce()
expect(posFn).toHaveBeenCalledOnce()
})
})
describe("on('mouseDown') — pointer events (S2.N10) — Phase B", () => {
it.todo(
"[Phase B/C] handle.on('mouseDown', handler) fires when pointer-down occurs within node bounding box"
)
it.todo(
'[Phase B/C] handler receives event with local x/y coordinates relative to node origin'
)
it.todo('[Phase B/C] returning true stops LiteGraph default mouse handling')
it.todo(
'[Phase B/C] listener is auto-removed when node is removed (no leak)'
)
})
describe("on('selected') / on('deselected') — selection focus (S2.N17) — Phase B", () => {
it.todo(
"[Phase B/C] handle.on('selected', handler) fires when node enters selected state"
)
it.todo(
"[Phase B/C] handle.on('deselected', handler) fires when node exits selected state"
)
it.todo(
'[Phase B/C] selected/deselected do not fire for programmatic selection with { silent: true }'
)
it.todo(
'[Phase B/C] isSelected() getter reflects current state at event fire time'
)
})
})

View File

@@ -1,363 +0,0 @@
// 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
//
// AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// This file asserts v1↔v2 parity for runtime DOM widget addition.
// v2 NodeHandle.addDOMWidget is removed per A15 — runtime widget addition
// is forbidden in the new API. All tests are wrapped with
// `axiomExcluded({...})` (vitest test.fails) and continue to run as
// regression alarms.
//
// Migration: v1 `node.addDOMWidget(...)` extensions migrate to one of —
// - Declare in Python INPUT_TYPES (preferred)
// - Boxed widget (BBOX-style)
// - Non-widget UI primitive via defineNode/defineExtension setup()
//
// The "compat-floor blast_radius ≥ 2.0 MUST pass before v2 ships"
// doctrine is retired (AXIOMS.md §Axiom-Excluded Test Annotation Policy).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'v2 NodeHandle does not expose addDOMWidget; the v1↔v2 parity scenario this file tests is no longer valid.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.migration.test.ts) ──────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
// vi.mock factories are hoisted; keep imported helpers behind arrows so
// the import binding is read lazily at factory invocation time.
vi.mock('@/world/worldInstance', () =>
worldInstanceMockFactory({ mockGetComponent, mockEntitiesWith })
)
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/world/entityIds', () => emptyMockFactory())
vi.mock('@/world/componentKey', () => componentKeyMockFactory())
vi.mock('@/extension-api/node', () => emptyMockFactory())
vi.mock('@/extension-api/widget', () => emptyMockFactory())
vi.mock('@/extension-api/lifecycle', () => emptyMockFactory())
import {
_clearExtensionsForTesting,
_setDispatchImplForTesting,
defineNode,
mountExtensionsForNode,
unmountExtensionsForNode
} from '@/services/extension-api-service'
import type { NodeEntityId } from '@/world/entityIds'
// ── V1 shim ───────────────────────────────────────────────────────────────────
// Minimal in-memory replica of v1 node.addDOMWidget + node.computeSize behavior.
interface V1DOMWidgetRecord {
name: string
type: string
element: HTMLElement
height: number
}
interface V1Node {
id: number
type: string
domWidgets: V1DOMWidgetRecord[]
computeSizeOverridden: boolean
computedSize: [number, number]
addDOMWidget(
name: string,
type: string,
element: HTMLElement,
opts?: { getHeight?: () => number }
): V1DOMWidgetRecord
_overrideComputeSize(fn: (out: [number, number]) => [number, number]): void
}
function createV1Node(id: number, type = 'TestNode'): V1Node {
const domWidgets: V1DOMWidgetRecord[] = []
return {
id,
type,
domWidgets,
computeSizeOverridden: false,
computedSize: [200, 100] as [number, number],
addDOMWidget(name, wtype, element, opts) {
const height = opts?.getHeight?.() ?? element.offsetHeight
const record: V1DOMWidgetRecord = { name, type: wtype, element, height }
domWidgets.push(record)
this.computedSize[1] += height
return record
},
_overrideComputeSize(fn) {
this.computeSizeOverridden = true
this.computedSize = fn(this.computedSize)
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeNodeId(n: number): NodeEntityId {
return `node:graph-uuid-bc05-mig:${n}` as unknown as NodeEntityId
}
function stubNodeType(id: NodeEntityId, comfyClass = 'TestNode') {
mockGetComponent.mockImplementation((eid, key: { name: string }) => {
if (eid !== id) return undefined
if (key.name === 'NodeType') return { type: comfyClass, comfyClass }
return undefined
})
}
function makeDiv(height = 120): HTMLElement {
const el = document.createElement('div')
Object.defineProperty(el, 'offsetHeight', {
value: height,
configurable: true
})
return el
}
const ALL_TEST_IDS = Array.from({ length: 12 }, (_, i) => makeNodeId(i + 1))
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.05 migration — custom DOM widgets and node sizing', () => {
let dispatchedCommands: Record<string, unknown>[]
beforeEach(() => {
vi.clearAllMocks()
dispatchedCommands = []
_clearExtensionsForTesting()
ALL_TEST_IDS.forEach((id) => unmountExtensionsForNode(id))
_setDispatchImplForTesting((cmd) => {
dispatchedCommands.push(cmd)
if (cmd.type === 'CreateWidget') {
return `widget:graph:${String(cmd.parentNodeId)}:${String(cmd.name)}`
}
return undefined
})
})
afterEach(() => {
_setDispatchImplForTesting(null)
})
describe('widget registration parity (S4.W2)', () => {
excluded('v1 addDOMWidget and v2 addDOMWidget both register a widget with the given name', () => {
const el = makeDiv()
// v1 pattern
const v1Node = createV1Node(1)
v1Node.addDOMWidget('editor', 'custom', el)
const v1Names = v1Node.domWidgets.map((w) => w.name)
// v2 pattern
const registeredNames: string[] = []
defineNode({
name: 'bc05.mig.register-parity',
nodeCreated(handle) {
const wh = handle.addDOMWidget({ name: 'editor', element: el })
registeredNames.push(wh.name)
}
})
const id = makeNodeId(1)
stubNodeType(id)
mountExtensionsForNode(id)
expect(registeredNames).toEqual(v1Names)
})
excluded('v1 opts.getHeight() value matches the v2 height option stored in the dispatch command', () => {
const el = makeDiv(0) // offsetHeight irrelevant
const reportedHeight = 200
// v1: getHeight callback
const v1Node = createV1Node(2)
v1Node.addDOMWidget('widget', 'custom', el, {
getHeight: () => reportedHeight
})
const v1Height = v1Node.domWidgets[0].height
// v2: explicit height option
defineNode({
name: 'bc05.mig.height-parity',
nodeCreated(handle) {
handle.addDOMWidget({
name: 'widget',
element: el,
height: reportedHeight
})
}
})
const id = makeNodeId(2)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmd = dispatchedCommands.find(
(c) => c.type === 'CreateWidget' && c.name === 'widget'
) as { options: { __domHeight: number } } | undefined
expect(createCmd?.options.__domHeight).toBe(v1Height)
})
excluded('v2 registers the same number of DOM widgets as v1 for a multi-widget node', () => {
// v1 pattern: two addDOMWidget calls
const v1Node = createV1Node(3)
v1Node.addDOMWidget('widgetA', 'custom', makeDiv(50))
v1Node.addDOMWidget('widgetB', 'custom', makeDiv(80))
const v1Count = v1Node.domWidgets.length
// v2 pattern
defineNode({
name: 'bc05.mig.multi-count',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'widgetA', element: makeDiv(50) })
handle.addDOMWidget({ name: 'widgetB', element: makeDiv(80) })
}
})
const id = makeNodeId(3)
stubNodeType(id)
mountExtensionsForNode(id)
const v2DomWidgets = dispatchedCommands.filter(
(c) => c.type === 'CreateWidget' && c.widgetType === 'DOM'
)
expect(v2DomWidgets).toHaveLength(v1Count)
})
})
describe('computeSize elimination (S2.N11)', () => {
excluded('v2 setHeight produces a SetWidgetOption command; v1 requires a computeSize override for the same effect', () => {
const el = makeDiv(100)
const newHeight = 400
// v1: manual computeSize override is required
const v1Node = createV1Node(4)
v1Node.addDOMWidget('widget', 'custom', el)
v1Node._overrideComputeSize((out) => [out[0], newHeight])
expect(v1Node.computeSizeOverridden).toBe(true)
// v2: no computeSize — just setHeight on the WidgetHandle
defineNode({
name: 'bc05.mig.no-compute-size',
nodeCreated(handle) {
const wh = handle.addDOMWidget({ name: 'widget', element: el })
wh.setHeight(newHeight)
}
})
const id = makeNodeId(4)
stubNodeType(id)
mountExtensionsForNode(id)
const heightCmd = dispatchedCommands.find(
(c) =>
c.type === 'SetWidgetOption' &&
c.key === '__domHeight' &&
c.value === newHeight
)
// v1 needed a computeSize override; v2 achieves the same via SetWidgetOption dispatch
expect(heightCmd).toBeDefined()
})
})
describe('cleanup parity', () => {
excluded('v1 requires manual removal in onRemoved; v2 auto-removes the element via scope disposal', () => {
const el = makeDiv()
document.body.appendChild(el)
// v1 pattern: manual teardown via onRemoved
let v1CleanedUp = false
const v1OnRemoved = () => {
el.remove()
v1CleanedUp = true
}
v1OnRemoved()
expect(v1CleanedUp).toBe(true)
// Re-attach for v2 test
document.body.appendChild(el)
expect(document.body.contains(el)).toBe(true)
// v2 pattern: auto-cleanup on scope dispose (via onScopeDispose in addDOMWidget)
defineNode({
name: 'bc05.mig.auto-cleanup',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'widget', element: el })
}
})
const id = makeNodeId(5)
stubNodeType(id)
mountExtensionsForNode(id)
unmountExtensionsForNode(id)
// Both v1 (manual) and v2 (auto) result in element absent after node removal
expect(document.body.contains(el)).toBe(false)
})
excluded('v2 auto-cleanup only removes the element registered via addDOMWidget, not unrelated elements', () => {
const registeredEl = makeDiv()
const unrelatedEl = makeDiv()
document.body.appendChild(registeredEl)
document.body.appendChild(unrelatedEl)
defineNode({
name: 'bc05.mig.scoped-cleanup',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'registered', element: registeredEl })
// unrelatedEl is NOT registered — must survive scope disposal
}
})
const id = makeNodeId(6)
stubNodeType(id)
mountExtensionsForNode(id)
unmountExtensionsForNode(id)
expect(document.body.contains(registeredEl)).toBe(false)
expect(document.body.contains(unrelatedEl)).toBe(true)
unrelatedEl.remove()
})
})
describe('Phase B deferred', () => {
it.todo(
// Phase B: requires real LiteGraph canvas + ECS DOM widget component.
'v1 computeSize override and v2 auto-computeSize produce identical node dimensions at render time (Phase B)'
)
it.todo(
// Phase B: requires WidgetComponentContainer wired.
'v1 node.widgets array and v2 NodeHandle.widgets() both include the DOM widget by name (Phase B)'
)
})
})

View File

@@ -1,179 +0,0 @@
// 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, expect, it, vi } from 'vitest'
import {
countEvidenceExcerpts,
createMiniComfyApp,
loadEvidenceSnippet,
runV1
} from '../harness'
// ── Minimal v1 DOM widget stub ────────────────────────────────────────────────
interface DOMWidget {
name: string
type: string
element: HTMLElement
height: number
}
interface V1NodeWithWidgets {
widgets: DOMWidget[]
}
function addDOMWidget(
node: V1NodeWithWidgets,
name: string,
type: string,
element: HTMLElement,
opts?: { getHeight?: () => number }
): DOMWidget {
const height = opts?.getHeight?.() ?? element.offsetHeight
const w: DOMWidget = { name, type, element, height }
node.widgets.push(w)
return w
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.05 v1 contract — custom DOM widgets and node sizing', () => {
describe('S4.W2 — node.addDOMWidget (synthetic)', () => {
it('widget returned by addDOMWidget has the given name', () => {
const node: V1NodeWithWidgets = { widgets: [] }
const el = document.createElement('div')
Object.defineProperty(el, 'offsetHeight', {
value: 120,
configurable: true
})
const w = addDOMWidget(node, 'editor', 'custom', el)
expect(w.name).toBe('editor')
expect(node.widgets).toHaveLength(1)
})
it('opts.getHeight() is used when provided (override > offsetHeight)', () => {
const node: V1NodeWithWidgets = { widgets: [] }
const el = document.createElement('div')
Object.defineProperty(el, 'offsetHeight', {
value: 120,
configurable: true
})
const w = addDOMWidget(node, 'editor', 'custom', el, {
getHeight: () => 200
})
expect(w.height).toBe(200)
})
it('widget is accessible in node.widgets by name after registration', () => {
const node: V1NodeWithWidgets = { widgets: [] }
const el = document.createElement('div')
addDOMWidget(node, 'preview', 'dom', el)
const found = node.widgets.find((w) => w.name === 'preview')
expect(found).toBeDefined()
expect(found!.element).toBe(el)
})
it.todo('DOM element appended to document')
it.todo('canvas render triggers opts.onDraw(ctx)')
it.todo('graph reload persistence')
})
describe('S2.N11 — node.computeSize override (synthetic)', () => {
it('assigning node.computeSize = fn overrides the default', () => {
const node: Record<string, unknown> = {
computeSize: (_out: [number, number]) => [140, 80] as [number, number]
}
const custom = vi.fn(
(_out: [number, number]) => [300, 150] as [number, number]
)
node.computeSize = custom
const result = (node.computeSize as typeof custom)([0, 0])
expect(custom).toHaveBeenCalledOnce()
expect(result).toEqual([300, 150])
})
it('overridden computeSize receives out array and returns [w,h]', () => {
const out: [number, number] = [0, 0]
const node = {
computeSize: (o: [number, number]): [number, number] => {
o[0] = 256
o[1] = 192
return [256, 192]
}
}
const result = node.computeSize(out)
expect(result[0]).toBe(256)
expect(result[1]).toBe(192)
})
it('computeSize result accounts for DOM widget reserved height', () => {
const widgetHeight = 120
const baseHeight = 80
const node = {
computeSize: (_out: [number, number]): [number, number] => [
200,
baseHeight + widgetHeight
]
}
const [, h] = node.computeSize([0, 0])
expect(h).toBe(baseHeight + widgetHeight)
})
it.todo(
'overridden computeSize is called by LiteGraph layout engine before rendering'
)
it.todo(
'computeSize override persists across graph load/reload if set in nodeCreated or beforeRegisterNodeDef'
)
})
describe('S4.W2 — evidence excerpts', () => {
it('S4.W2 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S4.W2')).toBeGreaterThan(0)
})
it('S4.W2 evidence snippet contains addDOMWidget fingerprint', () => {
const snippet = loadEvidenceSnippet('S4.W2', 0)
expect(snippet).toMatch(/addDOMWidget/i)
})
it('S4.W2 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S4.W2', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N11 — evidence excerpts', () => {
it('S2.N11 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N11')).toBeGreaterThan(0)
})
it('S2.N11 evidence snippet contains computeSize fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N11', 0)
expect(snippet).toMatch(/computeSize/i)
})
it('S2.N11 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N11', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
})

View File

@@ -1,338 +0,0 @@
// 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
//
// AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// v2 NodeHandle.addDOMWidget / addWidget surfaces removed. All tests in
// this file are wrapped with `axiomExcluded({...})` (vitest test.fails)
// and continue to run as regression alarms — if the v2 surface is
// ever re-introduced, these tests flip to FAIL.
//
// Migration paths for original consumers:
// - Declare in Python INPUT_TYPES
// - Boxed widget (e.g. BBOX [x,y,w,h])
// - Non-widget UI primitive via defineNode/defineExtension setup()
//
// The "compat-floor blast_radius ≥ 2.0 MUST pass before v2 ships"
// doctrine is retired (AXIOMS.md §Axiom-Excluded Test Annotation Policy).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'Widgets are schema-declared per A15; v2 NodeHandle does not expose addDOMWidget/addWidget.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.v2.test.ts) ────────────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
// vi.mock factories are hoisted; keep imported helpers behind arrows so
// the import binding is read lazily at factory invocation time.
vi.mock('@/world/worldInstance', () =>
worldInstanceMockFactory({ mockGetComponent, mockEntitiesWith })
)
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/world/entityIds', () => emptyMockFactory())
vi.mock('@/world/componentKey', () => componentKeyMockFactory())
vi.mock('@/extension-api/node', () => emptyMockFactory())
vi.mock('@/extension-api/widget', () => emptyMockFactory())
vi.mock('@/extension-api/lifecycle', () => emptyMockFactory())
import {
_clearExtensionsForTesting,
_setDispatchImplForTesting,
defineNode,
mountExtensionsForNode,
unmountExtensionsForNode
} from '@/services/extension-api-service'
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
// Stub for the removed `getDOMWidgetElement` export. The side table was
// deleted alongside the v2 addDOMWidget shim per D-ban-runtime-addwidget;
// tests that reference it remain (wrapped via axiomExcluded) so the
// resulting assertion failures continue to flag any re-introduction.
const getDOMWidgetElement = (
_widgetId: WidgetEntityId
): HTMLElement | undefined => undefined
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeNodeId(n: number): NodeEntityId {
return `node:graph-uuid-bc05:${n}` as unknown as NodeEntityId
}
function stubNodeType(id: NodeEntityId, comfyClass = 'TestNode') {
mockGetComponent.mockImplementation((eid, key: { name: string }) => {
if (eid !== id) return undefined
if (key.name === 'NodeType') return { type: comfyClass, comfyClass }
return undefined
})
}
function makeDiv(height = 120): HTMLElement {
const el = document.createElement('div')
Object.defineProperty(el, 'offsetHeight', {
value: height,
configurable: true
})
return el
}
const ALL_TEST_IDS = Array.from({ length: 10 }, (_, i) => makeNodeId(i + 1))
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
let dispatchedCommands: Record<string, unknown>[]
beforeEach(() => {
vi.clearAllMocks()
dispatchedCommands = []
_clearExtensionsForTesting()
ALL_TEST_IDS.forEach((id) => unmountExtensionsForNode(id))
_setDispatchImplForTesting((cmd) => {
dispatchedCommands.push(cmd)
// Return a synthetic widget entity ID for CreateWidget commands
if (cmd.type === 'CreateWidget') {
return `widget:graph:${String(cmd.parentNodeId)}:${String(cmd.name)}`
}
return undefined
})
})
afterEach(() => {
_setDispatchImplForTesting(null)
})
describe('NodeHandle.addDOMWidget(opts) — widget registration (S4.W2)', () => {
excluded('addDOMWidget dispatches a CreateWidget command with type "DOM" and the given name', () => {
const el = makeDiv()
defineNode({
name: 'bc05.v2.register',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'myEditor', element: el })
}
})
const id = makeNodeId(1)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmd = dispatchedCommands.find(
(c) => c.type === 'CreateWidget' && c.name === 'myEditor'
) as { widgetType: string } | undefined
expect(createCmd).toBeDefined()
expect(createCmd?.widgetType).toBe('DOM')
})
excluded('addDOMWidget returns a WidgetHandle with the correct name', () => {
let handleName: string | undefined
defineNode({
name: 'bc05.v2.handle-name',
nodeCreated(handle) {
const wh = handle.addDOMWidget({
name: 'preview',
element: makeDiv()
})
handleName = wh.name
}
})
const id = makeNodeId(2)
stubNodeType(id)
mountExtensionsForNode(id)
expect(handleName).toBe('preview')
})
excluded('addDOMWidget stores the DOM element in a side table (not in command options, for serializability)', () => {
const el = makeDiv()
let widgetId: WidgetEntityId | undefined
defineNode({
name: 'bc05.v2.element-stored',
nodeCreated(handle) {
const wh = handle.addDOMWidget({ name: 'canvas', element: el })
widgetId = wh.id as WidgetEntityId
}
})
const id = makeNodeId(3)
stubNodeType(id)
mountExtensionsForNode(id)
// Element is NOT in the command options (commands must be serializable)
const createCmd = dispatchedCommands.find(
(c) => c.type === 'CreateWidget' && c.name === 'canvas'
) as { options: Record<string, unknown> } | undefined
expect(createCmd?.options.__domElement).toBeUndefined()
// Element is stored in side table, retrievable via getDOMWidgetElement()
expect(widgetId).toBeDefined()
expect(getDOMWidgetElement(widgetId!)).toBe(el)
})
excluded('addDOMWidget uses the provided height option rather than offsetHeight when specified', () => {
const el = makeDiv(120) // offsetHeight = 120
const customHeight = 250
defineNode({
name: 'bc05.v2.custom-height',
nodeCreated(handle) {
handle.addDOMWidget({
name: 'editor',
element: el,
height: customHeight
})
}
})
const id = makeNodeId(4)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmd = dispatchedCommands.find(
(c) => c.type === 'CreateWidget' && c.name === 'editor'
) as { options: { __domHeight: number } } | undefined
expect(createCmd?.options.__domHeight).toBe(customHeight)
})
excluded('addDOMWidget falls back to element.offsetHeight when no height option is given', () => {
const el = makeDiv(88)
defineNode({
name: 'bc05.v2.fallback-height',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'preview', element: el })
}
})
const id = makeNodeId(5)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmd = dispatchedCommands.find(
(c) => c.type === 'CreateWidget' && c.name === 'preview'
) as { options: { __domHeight: number } } | undefined
expect(createCmd?.options.__domHeight).toBe(88)
})
excluded('DOM element is removed from the document when the node scope is disposed', () => {
const el = makeDiv()
document.body.appendChild(el)
expect(document.body.contains(el)).toBe(true)
defineNode({
name: 'bc05.v2.auto-cleanup',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'widget', element: el })
}
})
const id = makeNodeId(6)
stubNodeType(id)
mountExtensionsForNode(id)
// Unmounting the node scope triggers onScopeDispose → el.remove()
unmountExtensionsForNode(id)
expect(document.body.contains(el)).toBe(false)
})
})
describe('WidgetHandle geometry — setHeight (replaces S2.N11 computeSize override)', () => {
excluded('WidgetHandle.setHeight dispatches a SetWidgetOption command with key "__domHeight"', () => {
defineNode({
name: 'bc05.v2.set-height',
nodeCreated(handle) {
const wh = handle.addDOMWidget({
name: 'resizable',
element: makeDiv(100)
})
wh.setHeight(300)
}
})
const id = makeNodeId(7)
stubNodeType(id)
mountExtensionsForNode(id)
const setCmd = dispatchedCommands.find(
(c) =>
c.type === 'SetWidgetOption' &&
c.key === '__domHeight' &&
c.value === 300
)
expect(setCmd).toBeDefined()
})
excluded('multiple addDOMWidget calls each produce independent CreateWidget commands', () => {
defineNode({
name: 'bc05.v2.multi-widget',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'widgetA', element: makeDiv(50) })
handle.addDOMWidget({ name: 'widgetB', element: makeDiv(80) })
}
})
const id = makeNodeId(8)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmds = dispatchedCommands.filter(
(c) => c.type === 'CreateWidget' && c.widgetType === 'DOM'
)
expect(createCmds).toHaveLength(2)
const names = createCmds.map((c) => c.name)
expect(names).toContain('widgetA')
expect(names).toContain('widgetB')
})
})
describe('Phase B deferred', () => {
it.todo(
// Phase B: requires LiteGraph canvas integration.
// Auto-computeSize integration needs the actual LiteGraph node to reflect WidgetHandle.setHeight — deferred to Phase B.
'WidgetHandle.setHeight() triggers a node relayout — the node height reflects the new widget reservation (Phase B)'
)
it.todo(
// Phase B: requires real ECS DOM widget component.
'addDOMWidget widget is accessible via NodeHandle.widgets() by name (Phase B — needs WidgetComponentContainer wired)'
)
})
})

View File

@@ -1,42 +0,0 @@
// 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)', () => {
// COM-3668: Simon Tranter vetoed canvas-draw testing — no headless canvas renderer available.
// Canvas-level prototype override testing deferred post-D9 Phase C.
it.skip(
'extensions that replace LGraphCanvas.prototype methods in v1 continue to function alongside v2 NodeHandle.onDraw registrations without conflict'
)
it.skip(
'processContextMenu replacement in v1 is not disrupted by extensions migrated to v2 per-node APIs'
)
})
})

View File

@@ -1,189 +0,0 @@
// 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, expect, it } from 'vitest'
import {
countEvidenceExcerpts,
createMiniComfyApp,
loadEvidenceSnippet,
runV1
} from '../harness'
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.06 v1 contract — custom canvas drawing (per-node and canvas-level)', () => {
describe('S2.N9 — node.onDrawForeground (synthetic)', () => {
it('onDrawForeground callback is invoked with (ctx, visibleArea)', () => {
const mockCtx = { fillRect: () => {}, strokeRect: () => {} }
const mockArea = [0, 0, 800, 600]
const received: unknown[][] = []
const node = {
onDrawForeground(ctx: unknown, visibleArea: unknown) {
received.push([ctx, visibleArea])
}
}
node.onDrawForeground(mockCtx, mockArea)
expect(received).toHaveLength(1)
expect(received[0][0]).toBe(mockCtx)
expect(received[0][1]).toBe(mockArea)
})
it('ctx argument is the same object passed in (identity check)', () => {
const mockCtx = { fillRect: () => {} }
let capturedCtx: unknown
const node = {
onDrawForeground(ctx: unknown, _area: unknown) {
capturedCtx = ctx
}
}
node.onDrawForeground(mockCtx, [])
expect(capturedCtx).toBe(mockCtx)
})
it.todo(
'ctx passed to onDrawForeground is the same CanvasRenderingContext2D used by LiteGraph for the node layer'
)
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 (synthetic)', () => {
it('overriding a prototype method changes behavior for all instances', () => {
interface MockCanvas {
drawNodeShape(ctx: object, node: object): string
}
const LGraphCanvasProto: MockCanvas = { drawNodeShape: () => 'default' }
LGraphCanvasProto.drawNodeShape = (_ctx, _node) => 'custom'
const instance = Object.create(LGraphCanvasProto) as MockCanvas
expect(instance.drawNodeShape({}, {})).toBe('custom')
})
it('last-writer-wins — two overrides, second wins', () => {
interface MockCanvas {
drawNodeShape(ctx: object, node: object): string
}
const LGraphCanvasProto: MockCanvas = { drawNodeShape: () => 'default' }
LGraphCanvasProto.drawNodeShape = () => 'first'
LGraphCanvasProto.drawNodeShape = () => 'second'
const instance = Object.create(LGraphCanvasProto) as MockCanvas
expect(instance.drawNodeShape({}, {})).toBe('second')
})
it.todo('actual canvas rendering with CanvasRenderingContext2D')
it.todo('real LiteGraph canvas instance shares the same prototype')
})
describe('S3.C2 — ContextMenu global replacement (synthetic)', () => {
it('replacing processContextMenu replaces the handler', () => {
interface MockCanvas {
processContextMenu(event: object): string
}
const LGraphCanvasProto: MockCanvas = {
processContextMenu: () => 'default-menu'
}
LGraphCanvasProto.processContextMenu = (_event) => 'custom-menu'
const instance = Object.create(LGraphCanvasProto) as MockCanvas
expect(instance.processContextMenu({})).toBe('custom-menu')
})
it('calling original inside wrapper preserves default entries (chain-call test)', () => {
const entries: string[] = []
interface MockCanvas {
processContextMenu(event: object): void
}
const LGraphCanvasProto: MockCanvas = {
processContextMenu(_event: object) {
entries.push('default')
}
}
const original =
LGraphCanvasProto.processContextMenu.bind(LGraphCanvasProto)
LGraphCanvasProto.processContextMenu = function (event) {
entries.push('custom')
original(event)
}
const instance = Object.create(LGraphCanvasProto) as MockCanvas
instance.processContextMenu({})
expect(entries).toEqual(['custom', 'default'])
})
it.todo('actual canvas rendering')
it.todo('real LiteGraph canvas')
})
describe('S2.N9 — evidence excerpts', () => {
it('S2.N9 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N9')).toBeGreaterThan(0)
})
it('S2.N9 evidence snippet contains onDrawForeground fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N9', 0)
expect(snippet).toMatch(/onDrawForeground/i)
})
it('S2.N9 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N9', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S3.C1 — evidence excerpts', () => {
it('S3.C1 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S3.C1')).toBeGreaterThan(0)
})
it('S3.C1 evidence snippet contains drawNodeShape or prototype fingerprint', () => {
const count = countEvidenceExcerpts('S3.C1')
let found = false
for (let i = 0; i < count; i++) {
const snippet = loadEvidenceSnippet('S3.C1', i)
if (/drawNodeShape|prototype/i.test(snippet)) {
found = true
break
}
}
expect(
found,
'Expected at least one S3.C1 excerpt with drawNodeShape or prototype fingerprint'
).toBe(true)
})
it('S3.C1 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S3.C1', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S3.C2 — evidence excerpts', () => {
it.todo('S3.C2 evidence excerpts — pattern not yet in database snapshot')
})
})

View File

@@ -1,43 +0,0 @@
// 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)', () => {
// COM-3668: Simon Tranter vetoed canvas-draw testing — no headless canvas renderer available.
// Canvas-level prototype override testing deferred post-D9 Phase C.
it.skip(
'[D9 Phase C] v2 exposes no stable API for replacing LGraphCanvas.prototype.drawNodeShape — extensions using this pattern must remain on v1 shim'
)
it.skip(
'[D9 Phase C] v2 exposes no stable API for replacing processContextMenu — context-menu customization is deferred to the ComfyUI menu extension point'
)
it.skip(
'[D9 Phase C] blast-radius tracking: S3.C1 and S3.C2 overrides coexist with v2 per-node drawing without mutual interference'
)
})
})

View File

@@ -1,294 +0,0 @@
// 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 patching (onConnectInput/onConnectOutput/onConnectionsChange)
// → v2 node.on('connected') / node.on('disconnected')
//
// Phase A strategy: prove call-count parity between the two subscription styles
// using a synthetic event bus. Real graph-wiring and veto semantics need Phase B.
//
// I-TF.8.C1 — BC.07 migration wired assertions.
import { describe, expect, it, vi } from 'vitest'
import { effectScope, onScopeDispose } from 'vue'
import type {
NodeConnectedEvent,
NodeDisconnectedEvent,
NodeEntityId,
SlotEntityId,
SlotDirection
} from '@/extension-api/node'
import type { Unsubscribe } from '@/extension-api/events'
// ── V1 shim: prototype-assignment style ──────────────────────────────────────
// Models the v1 pattern where extensions assign methods to an LGraphNode-like
// prototype or instance. The "app" calls them directly.
interface V1NodeLike {
id: number
type: string
onConnectInput?: (slot: number, type: string) => boolean | void
onConnectOutput?: (slot: number, type: string) => boolean | void
onConnectionsChange?: (type: number, slot: number, connected: boolean) => void
}
function createV1App() {
const nodes: V1NodeLike[] = []
return {
addNode(node: V1NodeLike) {
nodes.push(node)
},
simulateConnectInput(nodeId: number, slot: number, type: string) {
const node = nodes.find((n) => n.id === nodeId)
return node?.onConnectInput?.(slot, type)
},
simulateConnectOutput(nodeId: number, slot: number, type: string) {
const node = nodes.find((n) => n.id === nodeId)
return node?.onConnectOutput?.(slot, type)
},
simulateConnectionsChange(
nodeId: number,
type: number,
slot: number,
connected: boolean
) {
const node = nodes.find((n) => n.id === nodeId)
node?.onConnectionsChange?.(type, slot, connected)
}
}
}
// ── V2 shim: node.on() style ──────────────────────────────────────────────────
type EventName = 'connected' | 'disconnected'
function createV2NodeBus() {
const connectedHandlers: Array<(e: NodeConnectedEvent) => void> = []
const disconnectedHandlers: Array<(e: NodeDisconnectedEvent) => void> = []
function on(
event: 'connected',
fn: (e: NodeConnectedEvent) => void
): Unsubscribe
function on(
event: 'disconnected',
fn: (e: NodeDisconnectedEvent) => void
): Unsubscribe
function on(event: EventName, fn: (e: never) => void): Unsubscribe {
if (event === 'connected') {
connectedHandlers.push(fn as (e: NodeConnectedEvent) => void)
return () => {
const i = connectedHandlers.indexOf(
fn as (e: NodeConnectedEvent) => void
)
if (i !== -1) connectedHandlers.splice(i, 1)
}
}
disconnectedHandlers.push(fn as (e: NodeDisconnectedEvent) => void)
return () => {
const i = disconnectedHandlers.indexOf(
fn as (e: NodeDisconnectedEvent) => void
)
if (i !== -1) disconnectedHandlers.splice(i, 1)
}
}
function emitConnected(e: NodeConnectedEvent) {
for (const h of [...connectedHandlers]) h(e)
}
function emitDisconnected(e: NodeDisconnectedEvent) {
for (const h of [...disconnectedHandlers]) h(e)
}
return {
on,
emitConnected,
emitDisconnected,
connectedHandlers,
disconnectedHandlers
}
}
// ── Fixture helpers ───────────────────────────────────────────────────────────
function makeSlot(name: string, dir: SlotDirection) {
return {
id: 1 as unknown as unknown as SlotEntityId,
name,
type: 'IMAGE',
direction: dir,
nodeId: 1 as unknown as unknown as NodeEntityId
} as const
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.07 migration — connection observation', () => {
describe('onConnectionsChange (S2.N3) → on("connected") / on("disconnected")', () => {
it('both v1 and v2 call their handlers the same number of times for the same events', () => {
const v1App = createV1App()
const bus = createV2NodeBus()
let v1Count = 0
let v2Count = 0
// v1: assign method on node instance
const node: V1NodeLike = {
id: 1,
type: 'KSampler',
onConnectionsChange(_type, _slot, _connected) {
v1Count++
}
}
v1App.addNode(node)
// v2: register via on()
bus.on('connected', () => {
v2Count++
})
bus.on('disconnected', () => {
v2Count++
})
// Simulate 2 connect + 1 disconnect
v1App.simulateConnectionsChange(1, 1, 0, true) // input connected
v1App.simulateConnectionsChange(1, 0, 1, true) // output connected
v1App.simulateConnectionsChange(1, 0, 0, false) // input disconnected
bus.emitConnected({
slot: makeSlot('in', 'input'),
remote: makeSlot('out', 'output')
})
bus.emitConnected({
slot: makeSlot('in2', 'input'),
remote: makeSlot('out2', 'output')
})
bus.emitDisconnected({ slot: makeSlot('in', 'input') })
expect(v2Count).toBe(v1Count)
expect(v2Count).toBe(3)
})
it('v2 handler receives typed slot info; v1 received raw numeric slot index', () => {
const bus = createV2NodeBus()
let receivedSlotName: string | undefined
bus.on('connected', (e) => {
receivedSlotName = e.slot.name
})
bus.emitConnected({
slot: makeSlot('latent', 'input'),
remote: makeSlot('LATENT', 'output')
})
// v2 gives the slot name directly; v1 gave a numeric index that required
// the extension to call node.inputs[slotIndex] to resolve the name.
expect(receivedSlotName).toBe('latent')
})
})
describe('onConnectInput / onConnectOutput (S2.N12, S2.N13) → on("connected")', () => {
it('on("connected") fires once per link established, matching v1 onConnectInput call count', () => {
const v1App = createV1App()
const bus = createV2NodeBus()
const v1Calls: number[] = []
const v2Calls: string[] = []
const node: V1NodeLike = {
id: 2,
type: 'TestNode',
onConnectInput(slot) {
v1Calls.push(slot)
}
}
v1App.addNode(node)
bus.on('connected', (e) => {
v2Calls.push(e.slot.name)
})
// Simulate 2 input connections
v1App.simulateConnectInput(2, 0, 'IMAGE')
v1App.simulateConnectInput(2, 1, 'LATENT')
bus.emitConnected({
slot: makeSlot('image', 'input'),
remote: makeSlot('img_out', 'output')
})
bus.emitConnected({
slot: makeSlot('latent', 'input'),
remote: makeSlot('lat_out', 'output')
})
expect(v2Calls).toHaveLength(v1Calls.length)
expect(v2Calls).toHaveLength(2)
})
})
describe('scope and cleanup', () => {
it('v2 on() listener is removed when the EffectScope is stopped (v1 prototype patch persists)', () => {
const bus = createV2NodeBus()
const handler = vi.fn()
// Mount in a scope
const scope = effectScope()
scope.run(() => {
const unsub = bus.on('connected', handler)
onScopeDispose(unsub)
})
bus.emitConnected({
slot: makeSlot('in', 'input'),
remote: makeSlot('out', 'output')
})
expect(handler).toHaveBeenCalledOnce()
// Stopping scope triggers onScopeDispose → unsub
scope.stop()
bus.emitConnected({
slot: makeSlot('in', 'input'),
remote: makeSlot('out', 'output')
})
expect(handler).toHaveBeenCalledOnce() // no new call
// v1 contrast: prototype methods have no scope — they leak until the node object is GC'd
})
it('unsubscribing one v2 listener does not affect other listeners on the same bus', () => {
const bus = createV2NodeBus()
const handlerA = vi.fn()
const handlerB = vi.fn()
const unsubA = bus.on('connected', handlerA)
bus.on('connected', handlerB)
bus.emitConnected({
slot: makeSlot('in', 'input'),
remote: makeSlot('out', 'output')
})
unsubA()
bus.emitConnected({
slot: makeSlot('in', 'input'),
remote: makeSlot('out', 'output')
})
expect(handlerA).toHaveBeenCalledOnce()
expect(handlerB).toHaveBeenCalledTimes(2)
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.07 migration — connection observation [Phase B/C]', () => {
it.todo(
'[Phase B/C] v1 onConnectInput returning false and v2 veto equivalent both leave the graph unwired'
)
it.todo(
'[Phase B/C] type coercion in v1 onConnectInput matches type coercion in v2 connected handler'
)
it.todo(
'[Phase B/C] v1 onConnectOutput veto and v2 equivalent both prevent connectionChange from firing on either endpoint'
)
it.todo(
'[Phase B/C] v2 on("connected") fires at the same point in the link-wiring sequence as v1 onConnectionsChange (after graph mutation)'
)
})

View File

@@ -1,269 +0,0 @@
// 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, expect, it } from 'vitest'
import {
countEvidenceExcerpts,
createMiniComfyApp,
loadEvidenceSnippet,
runV1
} from '../harness'
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.07 v1 contract — connection observation, intercept, and veto', () => {
describe('S2.N3 — onConnectionsChange: passive observation (synthetic)', () => {
it('callback fires when called with (type, slot, connected, link, ioSlot)', () => {
const received: unknown[][] = []
const node = {
onConnectionsChange(
type: number,
slot: number,
connected: boolean,
link: unknown,
ioSlot: unknown
) {
received.push([type, slot, connected, link, ioSlot])
}
}
const fakeLink = { id: 1, origin_id: 10, target_id: 20 }
const fakeIoSlot = { name: 'value', type: 'FLOAT' }
node.onConnectionsChange(1, 0, true, fakeLink, fakeIoSlot)
expect(received).toHaveLength(1)
expect(received[0]).toEqual([1, 0, true, fakeLink, fakeIoSlot])
})
it('fires for both source and target (simulate calling on each node in a pair)', () => {
const fired: string[] = []
const sourceNode = {
onConnectionsChange(
_type: number,
_slot: number,
_connected: boolean,
_link: unknown,
_ioSlot: unknown
) {
fired.push('source')
}
}
const targetNode = {
onConnectionsChange(
_type: number,
_slot: number,
_connected: boolean,
_link: unknown,
_ioSlot: unknown
) {
fired.push('target')
}
}
const fakeLink = { id: 2 }
sourceNode.onConnectionsChange(2, 0, true, fakeLink, undefined)
targetNode.onConnectionsChange(1, 0, true, fakeLink, undefined)
expect(fired).toEqual(['source', 'target'])
})
it.todo('real LiteGraph graph wiring')
it.todo('link object from LiteGraph')
})
describe('S2.N12 — onConnectInput: intercept and veto incoming connections (synthetic)', () => {
it('returning false from onConnectInput vetoes the connection', () => {
const node = {
onConnectInput(
_slot: number,
_type: string,
_link: unknown,
_sourceNode: unknown,
_sourceSlot: number
): boolean {
return false
}
}
const result = node.onConnectInput(0, 'FLOAT', {}, {}, 0)
const vetoed = result === false
expect(vetoed).toBe(true)
})
it('returning true allows connection', () => {
const node = {
onConnectInput(
_slot: number,
_type: string,
_link: unknown,
_sourceNode: unknown,
_sourceSlot: number
): boolean {
return true
}
}
const result = node.onConnectInput(0, 'FLOAT', {}, {}, 0)
expect(result).toBe(true)
})
it('receives (slot, type, link, sourceNode, sourceSlot) args', () => {
const received: unknown[] = []
const node = {
onConnectInput(
slot: number,
type: string,
link: unknown,
sourceNode: unknown,
sourceSlot: number
): boolean {
received.push(slot, type, link, sourceNode, sourceSlot)
return true
}
}
const fakeLink = { id: 3 }
const fakeSource = { id: 99 }
node.onConnectInput(2, 'IMAGE', fakeLink, fakeSource, 1)
expect(received).toEqual([2, 'IMAGE', fakeLink, fakeSource, 1])
})
it.todo('real LiteGraph graph wiring')
})
describe('S2.N13 — onConnectOutput: intercept and veto outgoing connections (synthetic)', () => {
it('returning false vetoes outgoing connection', () => {
const node = {
onConnectOutput(
_slot: number,
_type: string,
_link: unknown,
_targetNode: unknown,
_targetSlot: number
): boolean {
return false
}
}
const result = node.onConnectOutput(0, 'LATENT', {}, {}, 0)
expect(result).toBe(false)
})
it('veto means onConnectionsChange does NOT fire', () => {
let changesFired = false
const outputNode = {
onConnectOutput(
_slot: number,
_type: string,
_link: unknown,
_targetNode: unknown,
_targetSlot: number
): boolean {
return false
},
onConnectionsChange(
_type: number,
_slot: number,
_connected: boolean,
_link: unknown,
_ioSlot: unknown
) {
changesFired = true
}
}
const vetoed =
outputNode.onConnectOutput(0, 'LATENT', {}, {}, 0) === false
if (!vetoed) {
outputNode.onConnectionsChange(2, 0, true, {}, undefined)
}
expect(changesFired).toBe(false)
})
it('returning false vetoes outgoing connection — same pattern as onConnectInput', () => {
const results: boolean[] = []
const nodeAllow = {
onConnectOutput(): boolean {
return true
}
}
const nodeVeto = {
onConnectOutput(): boolean {
return false
}
}
results.push(nodeAllow.onConnectOutput())
results.push(nodeVeto.onConnectOutput())
expect(results).toEqual([true, false])
})
it.todo('real LiteGraph graph wiring')
it.todo('link object from LiteGraph')
})
describe('S2.N3 — evidence excerpts', () => {
it('S2.N3 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N3')).toBeGreaterThan(0)
})
it('S2.N3 evidence snippet contains onConnectionsChange fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N3', 0)
expect(snippet).toMatch(/onConnectionsChange/i)
})
it('S2.N3 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N3', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N12 — evidence excerpts', () => {
it('S2.N12 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N12')).toBeGreaterThan(0)
})
it('S2.N12 evidence snippet contains onConnectInput fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N12', 0)
expect(snippet).toMatch(/onConnectInput/i)
})
it('S2.N12 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N12', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
describe('S2.N13 — evidence excerpts', () => {
it('S2.N13 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N13')).toBeGreaterThan(0)
})
it('S2.N13 evidence snippet contains onConnectOutput fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N13', 0)
expect(snippet).toMatch(/onConnectOutput/i)
})
it('S2.N13 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N13', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
})

View File

@@ -1,305 +0,0 @@
// 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: MUST pass before v2 ships
// v2 replacement: node.on('connected', handler), node.on('disconnected', handler)
//
// Phase A strategy: prove the registration contract (on() returns Unsubscribe,
// unsubscribe stops future calls, multiple listeners are independent) using a
// minimal typed event emitter that mirrors the service contract without the ECS
// dependency. Event-firing from real World mutations is marked todo(Phase B).
//
// I-TF.8.C1 — BC.07 v2 wired assertions.
import { describe, expect, it, vi } from 'vitest'
import type {
NodeConnectedEvent,
NodeDisconnectedEvent,
SlotEntityId,
NodeEntityId,
SlotDirection
} from '@/extension-api/node'
import type { Unsubscribe } from '@/extension-api/events'
// ── Minimal typed event emitter ───────────────────────────────────────────────
// Models the service's node.on() registration contract without ECS.
// The real service wires these to Vue watch() calls on World components (Phase B).
type SupportedEvent = 'connected' | 'disconnected'
interface HandlerEntry<E> {
handler: (event: E) => void
unsub: Unsubscribe
}
function createNodeEventBus() {
const connectedHandlers: HandlerEntry<NodeConnectedEvent>[] = []
const disconnectedHandlers: HandlerEntry<NodeDisconnectedEvent>[] = []
function on(
event: 'connected',
handler: (e: NodeConnectedEvent) => void
): Unsubscribe
function on(
event: 'disconnected',
handler: (e: NodeDisconnectedEvent) => void
): Unsubscribe
function on(event: SupportedEvent, handler: (e: never) => void): Unsubscribe {
if (event === 'connected') {
const entry: HandlerEntry<NodeConnectedEvent> = {
handler: handler as (e: NodeConnectedEvent) => void,
unsub: () => {
const idx = connectedHandlers.indexOf(entry)
if (idx !== -1) connectedHandlers.splice(idx, 1)
}
}
connectedHandlers.push(entry)
return entry.unsub
} else {
const entry: HandlerEntry<NodeDisconnectedEvent> = {
handler: handler as (e: NodeDisconnectedEvent) => void,
unsub: () => {
const idx = disconnectedHandlers.indexOf(entry)
if (idx !== -1) disconnectedHandlers.splice(idx, 1)
}
}
disconnectedHandlers.push(entry)
return entry.unsub
}
}
function emitConnected(event: NodeConnectedEvent) {
for (const { handler } of [...connectedHandlers]) {
try {
handler(event)
} catch {
// Error isolation: one handler throwing should not prevent others from firing
}
}
}
function emitDisconnected(event: NodeDisconnectedEvent) {
for (const { handler } of [...disconnectedHandlers]) {
try {
handler(event)
} catch {
// Error isolation: one handler throwing should not prevent others from firing
}
}
}
return { on, emitConnected, emitDisconnected }
}
// ── Fixture helpers ───────────────────────────────────────────────────────────
function makeSlotId(n: number) {
return n as unknown as unknown as SlotEntityId
}
function makeNodeId(n: number) {
return n as unknown as unknown as NodeEntityId
}
function makeSlot(name: string, dir: SlotDirection, nodeId = makeNodeId(1)) {
return {
id: makeSlotId((Math.random() * 1e9) | 0),
name,
type: 'IMAGE',
direction: dir,
nodeId: nodeId
} as const
}
function makeConnectedEvent(
localName = 'input',
remoteName = 'output'
): NodeConnectedEvent {
return {
slot: makeSlot(localName, 'input'),
remote: makeSlot(remoteName, 'output', makeNodeId(2))
}
}
function makeDisconnectedEvent(slotName = 'input'): NodeDisconnectedEvent {
return { slot: makeSlot(slotName, 'input') }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.07 v2 contract — connection observation', () => {
describe('node.on("connected") — registration shape', () => {
it('on("connected", fn) returns an Unsubscribe function', () => {
const bus = createNodeEventBus()
const unsub = bus.on('connected', () => {})
expect(typeof unsub).toBe('function')
})
it('registered handler is called when a connected event fires', () => {
const bus = createNodeEventBus()
const handler = vi.fn()
bus.on('connected', handler)
bus.emitConnected(makeConnectedEvent())
expect(handler).toHaveBeenCalledOnce()
})
it('handler receives a NodeConnectedEvent with slot and remote fields', () => {
const bus = createNodeEventBus()
let received: NodeConnectedEvent | undefined
bus.on('connected', (e) => {
received = e
})
const evt = makeConnectedEvent('image_in', 'image_out')
bus.emitConnected(evt)
expect(received).toBeDefined()
expect(received!.slot.name).toBe('image_in')
expect(received!.remote.name).toBe('image_out')
expect(received!.slot.direction).toBe('input')
expect(received!.remote.direction).toBe('output')
})
it('calling Unsubscribe prevents future connected events from reaching the handler', () => {
const bus = createNodeEventBus()
const handler = vi.fn()
const unsub = bus.on('connected', handler)
bus.emitConnected(makeConnectedEvent())
expect(handler).toHaveBeenCalledOnce()
unsub()
bus.emitConnected(makeConnectedEvent())
expect(handler).toHaveBeenCalledOnce() // no new call
})
it('calling Unsubscribe twice is safe (idempotent)', () => {
const bus = createNodeEventBus()
const unsub = bus.on('connected', vi.fn())
expect(() => {
unsub()
unsub()
}).not.toThrow()
})
it('multiple handlers all fire; unsubscribing one does not affect the others', () => {
const bus = createNodeEventBus()
const handlerA = vi.fn()
const handlerB = vi.fn()
const handlerC = vi.fn()
const unsubA = bus.on('connected', handlerA)
bus.on('connected', handlerB)
bus.on('connected', handlerC)
bus.emitConnected(makeConnectedEvent())
expect(handlerA).toHaveBeenCalledOnce()
expect(handlerB).toHaveBeenCalledOnce()
expect(handlerC).toHaveBeenCalledOnce()
unsubA()
bus.emitConnected(makeConnectedEvent())
expect(handlerA).toHaveBeenCalledOnce() // still just once
expect(handlerB).toHaveBeenCalledTimes(2)
expect(handlerC).toHaveBeenCalledTimes(2)
})
})
describe('node.on("disconnected") — registration shape', () => {
it('on("disconnected", fn) returns an Unsubscribe function', () => {
const bus = createNodeEventBus()
const unsub = bus.on('disconnected', () => {})
expect(typeof unsub).toBe('function')
})
it('handler receives a NodeDisconnectedEvent with a slot field', () => {
const bus = createNodeEventBus()
let received: NodeDisconnectedEvent | undefined
bus.on('disconnected', (e) => {
received = e
})
const evt = makeDisconnectedEvent('latent_in')
bus.emitDisconnected(evt)
expect(received).toBeDefined()
expect(received!.slot.name).toBe('latent_in')
})
it('Unsubscribe prevents future disconnected events', () => {
const bus = createNodeEventBus()
const handler = vi.fn()
const unsub = bus.on('disconnected', handler)
bus.emitDisconnected(makeDisconnectedEvent())
unsub()
bus.emitDisconnected(makeDisconnectedEvent())
expect(handler).toHaveBeenCalledOnce()
})
})
describe('handler error isolation', () => {
it('a throwing handler does not prevent subsequent handlers from firing', () => {
const bus = createNodeEventBus()
const handlerA = vi.fn(() => {
throw new Error('handler A exploded')
})
const handlerB = vi.fn()
const handlerC = vi.fn()
bus.on('connected', handlerA)
bus.on('connected', handlerB)
bus.on('connected', handlerC)
// Emit should not throw to the caller; handlers B and C should still fire
expect(() => bus.emitConnected(makeConnectedEvent())).not.toThrow()
expect(handlerA).toHaveBeenCalledOnce()
expect(handlerB).toHaveBeenCalledOnce()
expect(handlerC).toHaveBeenCalledOnce()
})
it('a throwing disconnected handler does not prevent subsequent handlers from firing', () => {
const bus = createNodeEventBus()
const handlerA = vi.fn(() => {
throw new Error('disconnect handler failed')
})
const handlerB = vi.fn()
bus.on('disconnected', handlerA)
bus.on('disconnected', handlerB)
expect(() => bus.emitDisconnected(makeDisconnectedEvent())).not.toThrow()
expect(handlerA).toHaveBeenCalledOnce()
expect(handlerB).toHaveBeenCalledOnce()
})
})
describe('connected vs disconnected isolation', () => {
it('connected listener does not fire on disconnected events', () => {
const bus = createNodeEventBus()
const connectedFn = vi.fn()
const disconnectedFn = vi.fn()
bus.on('connected', connectedFn)
bus.on('disconnected', disconnectedFn)
bus.emitDisconnected(makeDisconnectedEvent())
expect(connectedFn).not.toHaveBeenCalled()
expect(disconnectedFn).toHaveBeenCalledOnce()
bus.emitConnected(makeConnectedEvent())
expect(connectedFn).toHaveBeenCalledOnce()
expect(disconnectedFn).toHaveBeenCalledOnce()
})
})
})
// ── Phase B stubs — need real ECS World + reactive dispatch ───────────────────
describe('BC.07 v2 contract — connection observation [Phase B/C]', () => {
it.todo(
'[Phase B/C] node.on("connected") fires when a real link is added to the World via ECS command'
)
it.todo(
'[Phase B/C] node.on("disconnected") fires when a link is removed from the World'
)
it.todo(
'[Phase B/C] handler registered via on() is removed by scope.stop() (onScopeDispose integration)'
)
it.todo(
'[Phase B/C] veto/intercept: returning false from connectInput handler prevents the link from being wired (if adopted in Phase B API)'
)
it.todo(
'[Phase B/C] type coercion: mutating event type inside a connection handler is reflected in the wired link'
)
})

View File

@@ -1,711 +0,0 @@
// 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)
//
// These tests verify behavioral equivalence between v1 and v2 APIs using synthetic harnesses.
import { describe, it, expect } from 'vitest'
// ── V1 Synthetic Types (from bc-08.v1) ───────────────────────────────────────
interface MockLinkV1 {
id: number
origin_id: number
origin_slot: number
target_id: number
target_slot: number
}
interface MockSlotV1 {
name: string
type: string
link: number | null
}
interface MockNodeV1 {
id: number
type: string
inputs: MockSlotV1[]
outputs: MockSlotV1[]
onConnectionsChange?: (
side: number,
slot: number,
connect: boolean,
link: MockLinkV1 | null,
ioSlot: MockSlotV1
) => void
}
interface MockGraphV1 {
links: Map<number, MockLinkV1>
_nextLinkId: number
add(node: MockNodeV1): void
getNodeById(id: number): MockNodeV1 | undefined
_createLink(
srcNode: MockNodeV1,
srcSlot: number,
dstNode: MockNodeV1,
dstSlot: number
): MockLinkV1 | null
_removeLink(linkId: number): void
}
// ── V2 Synthetic Types (from bc-08.v2) ───────────────────────────────────────
interface MockSlotV2 {
name: string
type: string
link: number | null
}
interface MockLinkV2 {
id: number
origin_id: string
origin_slot: number
target_id: string
target_slot: number
_invalid?: boolean
}
interface MockWorldV2 {
links: Map<number, MockLinkV2>
nodes: Map<string, MockNodeInternalV2>
_nextLinkId: number
}
interface ConnectionChangeEventV2 {
side: 'input' | 'output'
slotIndex: number
connected: boolean
linkId: number | null
}
interface MockNodeInternalV2 {
id: string
type: string
inputs: MockSlotV2[]
outputs: MockSlotV2[]
connectionListeners: Array<(e: ConnectionChangeEventV2) => void>
}
interface LinkHandleV2 {
readonly id: number
readonly isValid: () => boolean
}
interface NodeHandleV2 {
readonly id: string
readonly type: string
connect(
srcSlot: number,
targetHandle: NodeHandleV2,
dstSlot: number
): LinkHandleV2 | null
disconnectInput(slotIndex: number): void
on(
event: 'connectionChange',
handler: (e: ConnectionChangeEventV2) => void
): () => void
}
// ── V1 Synthetic Implementations ─────────────────────────────────────────────
function createMockGraphV1(): MockGraphV1 {
const nodes = new Map<number, MockNodeV1>()
const links = new Map<number, MockLinkV1>()
let nextLinkId = 1
return {
links,
_nextLinkId: nextLinkId,
add(node: MockNodeV1) {
nodes.set(node.id, node)
},
getNodeById(id: number) {
return nodes.get(id)
},
_createLink(srcNode, srcSlot, dstNode, dstSlot) {
const srcSlotObj = srcNode.outputs[srcSlot]
const dstSlotObj = dstNode.inputs[dstSlot]
if (!srcSlotObj || !dstSlotObj) return null
if (
srcSlotObj.type !== dstSlotObj.type &&
srcSlotObj.type !== '*' &&
dstSlotObj.type !== '*'
) {
return null
}
if (dstSlotObj.link !== null) {
this._removeLink(dstSlotObj.link)
}
const link: MockLinkV1 = {
id: nextLinkId++,
origin_id: srcNode.id,
origin_slot: srcSlot,
target_id: dstNode.id,
target_slot: dstSlot
}
links.set(link.id, link)
dstSlotObj.link = link.id
return link
},
_removeLink(linkId) {
const link = links.get(linkId)
if (!link) return
const dstNode = nodes.get(link.target_id)
if (dstNode) {
const dstSlot = dstNode.inputs[link.target_slot]
if (dstSlot && dstSlot.link === linkId) {
dstSlot.link = null
}
}
links.delete(linkId)
}
}
}
interface MockNodeV1WithMethods extends MockNodeV1 {
connect: (
srcSlot: number,
targetNode: MockNodeV1WithMethods,
dstSlot: number,
graph: MockGraphV1
) => MockLinkV1 | null
disconnectInput: (slot: number, graph: MockGraphV1) => void
}
function createMockNodeV1(
id: number,
type: string,
inputs: Array<{ name: string; type: string }>,
outputs: Array<{ name: string; type: string }>
): MockNodeV1WithMethods {
const node: MockNodeV1WithMethods = {
id,
type,
inputs: inputs.map((i) => ({ ...i, link: null })),
outputs: outputs.map((o) => ({ ...o, link: null })),
onConnectionsChange: undefined,
connect(srcSlot, targetNode, dstSlot, graph) {
const link = graph._createLink(node, srcSlot, targetNode, dstSlot)
if (link) {
if (node.onConnectionsChange) {
node.onConnectionsChange(
2,
srcSlot,
true,
link,
node.outputs[srcSlot]
)
}
if (targetNode.onConnectionsChange) {
targetNode.onConnectionsChange(
1,
dstSlot,
true,
link,
targetNode.inputs[dstSlot]
)
}
}
return link
},
disconnectInput(slot, graph) {
const slotObj = node.inputs[slot]
if (!slotObj || slotObj.link === null) return
const link = graph.links.get(slotObj.link)
if (!link) return
const srcNode = graph.getNodeById(link.origin_id) as
| MockNodeV1WithMethods
| undefined
graph._removeLink(slotObj.link)
if (node.onConnectionsChange) {
node.onConnectionsChange(1, slot, false, null, slotObj)
}
if (srcNode?.onConnectionsChange) {
srcNode.onConnectionsChange(
2,
link.origin_slot,
false,
null,
srcNode.outputs[link.origin_slot]
)
}
}
}
return node
}
// ── V2 Synthetic Implementations ─────────────────────────────────────────────
class TypeMismatchError extends Error {
constructor(srcType: string, dstType: string) {
super(`Cannot connect ${srcType} to ${dstType}: type mismatch`)
this.name = 'TypeMismatchError'
}
}
function createMockWorldV2(): MockWorldV2 {
return {
links: new Map(),
nodes: new Map(),
_nextLinkId: 1
}
}
function createNodeHandleV2(
world: MockWorldV2,
id: string,
type: string,
inputs: Array<{ name: string; type: string }>,
outputs: Array<{ name: string; type: string }>
): NodeHandleV2 {
const internal: MockNodeInternalV2 = {
id,
type,
inputs: inputs.map((i) => ({ ...i, link: null })),
outputs: outputs.map((o) => ({ ...o, link: null })),
connectionListeners: []
}
world.nodes.set(id, internal)
const handle: NodeHandleV2 = {
get id() {
return internal.id
},
get type() {
return internal.type
},
connect(srcSlot, targetHandle, dstSlot) {
const srcSlotObj = internal.outputs[srcSlot]
const targetInternal = world.nodes.get(targetHandle.id)
if (!targetInternal) return null
const dstSlotObj = targetInternal.inputs[dstSlot]
if (!srcSlotObj || !dstSlotObj) return null
if (
srcSlotObj.type !== dstSlotObj.type &&
srcSlotObj.type !== '*' &&
dstSlotObj.type !== '*'
) {
throw new TypeMismatchError(srcSlotObj.type, dstSlotObj.type)
}
if (dstSlotObj.link !== null) {
const oldLink = world.links.get(dstSlotObj.link)
if (oldLink) {
oldLink._invalid = true
world.links.delete(dstSlotObj.link)
}
dstSlotObj.link = null
}
const linkId = world._nextLinkId++
const link: MockLinkV2 = {
id: linkId,
origin_id: internal.id,
origin_slot: srcSlot,
target_id: targetInternal.id,
target_slot: dstSlot
}
world.links.set(linkId, link)
dstSlotObj.link = linkId
internal.connectionListeners.forEach((fn) =>
fn({ side: 'output', slotIndex: srcSlot, connected: true, linkId })
)
targetInternal.connectionListeners.forEach((fn) =>
fn({ side: 'input', slotIndex: dstSlot, connected: true, linkId })
)
return {
get id() {
return linkId
},
isValid() {
const l = world.links.get(linkId)
return l !== undefined && !l._invalid
}
}
},
disconnectInput(slotIndex) {
const slot = internal.inputs[slotIndex]
if (!slot || slot.link === null) return
const link = world.links.get(slot.link)
if (!link) return
const srcNode = world.nodes.get(link.origin_id)
link._invalid = true
world.links.delete(slot.link)
slot.link = null
internal.connectionListeners.forEach((fn) =>
fn({ side: 'input', slotIndex, connected: false, linkId: null })
)
if (srcNode) {
srcNode.connectionListeners.forEach((fn) =>
fn({
side: 'output',
slotIndex: link.origin_slot,
connected: false,
linkId: null
})
)
}
},
on(event, handler) {
if (event !== 'connectionChange')
throw new Error(`Unknown event: ${event}`)
internal.connectionListeners.push(handler)
return () => {
const idx = internal.connectionListeners.indexOf(handler)
if (idx !== -1) internal.connectionListeners.splice(idx, 1)
}
}
}
return handle
}
// ── Migration Tests ──────────────────────────────────────────────────────────
describe('BC.08 migration — programmatic linking', () => {
describe('connect() equivalence', () => {
it('v1 node.connect(srcSlot, targetNode, dstSlot) and v2 NodeHandle.connect(srcSlot, targetHandle, dstSlot) produce identical graph link state', () => {
// V1
const graphV1 = createMockGraphV1()
const srcV1 = createMockNodeV1(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV1 = createMockNodeV1(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graphV1.add(srcV1)
graphV1.add(dstV1)
const linkV1 = srcV1.connect(0, dstV1, 0, graphV1)
// V2
const worldV2 = createMockWorldV2()
const srcV2 = createNodeHandleV2(
worldV2,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV2 = createNodeHandleV2(
worldV2,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
const linkV2 = srcV2.connect(0, dstV2, 0)
// Both create exactly one link
expect(graphV1.links.size).toBe(1)
expect(worldV2.links.size).toBe(1)
// Link state is equivalent
expect(linkV1).not.toBeNull()
expect(linkV2).not.toBeNull()
expect(linkV1!.origin_slot).toBe(0)
expect(linkV1!.target_slot).toBe(0)
const v2Link = worldV2.links.get(linkV2!.id)!
expect(v2Link.origin_slot).toBe(0)
expect(v2Link.target_slot).toBe(0)
})
it('link id returned by v2 connect() matches the id on the underlying LGraph link created by an equivalent v1 call', () => {
// Both should start counting from 1
const graphV1 = createMockGraphV1()
const srcV1 = createMockNodeV1(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV1 = createMockNodeV1(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graphV1.add(srcV1)
graphV1.add(dstV1)
const linkV1 = srcV1.connect(0, dstV1, 0, graphV1)
const worldV2 = createMockWorldV2()
const srcV2 = createNodeHandleV2(
worldV2,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV2 = createNodeHandleV2(
worldV2,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
const linkV2 = srcV2.connect(0, dstV2, 0)
// Both start from 1
expect(linkV1!.id).toBe(1)
expect(linkV2!.id).toBe(1)
})
it('v2 connect() with a type-incompatible pair raises a typed error; v1 returns null — callers must handle both forms during migration', () => {
// V1: returns null
const graphV1 = createMockGraphV1()
const srcV1 = createMockNodeV1(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV1 = createMockNodeV1(
2,
'SaveImage',
[{ name: 'images', type: 'IMAGE' }],
[]
)
graphV1.add(srcV1)
graphV1.add(dstV1)
const linkV1 = srcV1.connect(0, dstV1, 0, graphV1)
expect(linkV1).toBeNull()
// V2: throws TypeMismatchError
const worldV2 = createMockWorldV2()
const srcV2 = createNodeHandleV2(
worldV2,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV2 = createNodeHandleV2(
worldV2,
'node-2',
'SaveImage',
[{ name: 'images', type: 'IMAGE' }],
[]
)
expect(() => srcV2.connect(0, dstV2, 0)).toThrow(TypeMismatchError)
// Both leave graph unchanged
expect(graphV1.links.size).toBe(0)
expect(worldV2.links.size).toBe(0)
})
})
describe('disconnectInput() equivalence', () => {
it('v1 node.disconnectInput(slot) and v2 NodeHandle.disconnectInput(slotIndex) both leave the graph with no link on that slot', () => {
// V1
const graphV1 = createMockGraphV1()
const srcV1 = createMockNodeV1(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV1 = createMockNodeV1(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graphV1.add(srcV1)
graphV1.add(dstV1)
srcV1.connect(0, dstV1, 0, graphV1)
expect(graphV1.links.size).toBe(1)
dstV1.disconnectInput(0, graphV1)
expect(graphV1.links.size).toBe(0)
expect(dstV1.inputs[0].link).toBeNull()
// V2
const worldV2 = createMockWorldV2()
const srcV2 = createNodeHandleV2(
worldV2,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV2 = createNodeHandleV2(
worldV2,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
srcV2.connect(0, dstV2, 0)
expect(worldV2.links.size).toBe(1)
dstV2.disconnectInput(0)
expect(worldV2.links.size).toBe(0)
})
it("onConnectionsChange (v1) and on('connectionChange') (v2) both fire for the same disconnect operation with equivalent payload data", () => {
// V1
const v1Calls: Array<{ side: number; slot: number; connect: boolean }> =
[]
const graphV1 = createMockGraphV1()
const srcV1 = createMockNodeV1(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV1 = createMockNodeV1(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graphV1.add(srcV1)
graphV1.add(dstV1)
srcV1.connect(0, dstV1, 0, graphV1)
dstV1.onConnectionsChange = (side, slot, connect) => {
v1Calls.push({ side, slot, connect })
}
dstV1.disconnectInput(0, graphV1)
// V2
const v2Calls: Array<{
side: string
slotIndex: number
connected: boolean
}> = []
const worldV2 = createMockWorldV2()
const srcV2 = createNodeHandleV2(
worldV2,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV2 = createNodeHandleV2(
worldV2,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
srcV2.connect(0, dstV2, 0)
dstV2.on('connectionChange', (e) => {
v2Calls.push({
side: e.side,
slotIndex: e.slotIndex,
connected: e.connected
})
})
dstV2.disconnectInput(0)
// Both fire exactly once on the target node
expect(v1Calls).toHaveLength(1)
expect(v2Calls).toHaveLength(1)
// V1 side=1 (input) corresponds to V2 side='input'
expect(v1Calls[0].side).toBe(1)
expect(v2Calls[0].side).toBe('input')
// Same slot index
expect(v1Calls[0].slot).toBe(0)
expect(v2Calls[0].slotIndex).toBe(0)
// Both indicate disconnect
expect(v1Calls[0].connect).toBe(false)
expect(v2Calls[0].connected).toBe(false)
})
})
describe('handle vs. raw node reference', () => {
it('v2 NodeHandle.connect() accepts a NodeHandle for targetHandle; passing a raw LGraphNode instance would require migration', () => {
// V2 API requires NodeHandle, not raw node reference
// This test verifies that the v2 API works with NodeHandle
const worldV2 = createMockWorldV2()
const srcV2 = createNodeHandleV2(
worldV2,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstV2 = createNodeHandleV2(
worldV2,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
// Connect using NodeHandle (the v2 way)
const linkHandle = srcV2.connect(0, dstV2, 0)
expect(linkHandle).not.toBeNull()
expect(linkHandle!.isValid()).toBe(true)
// Verify the link was created correctly
expect(worldV2.links.size).toBe(1)
})
it('NodeHandle obtained from v2 nodeCreated correctly wraps the same node that v1 connect() would operate on', () => {
// Both v1 and v2 operate on the same conceptual node
// V1 uses numeric id, V2 uses string entityId, but they refer to the same entity
const graphV1 = createMockGraphV1()
const nodeV1 = createMockNodeV1(
42,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
graphV1.add(nodeV1)
const worldV2 = createMockWorldV2()
const handleV2 = createNodeHandleV2(
worldV2,
'node-42',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
// Both represent a KSampler with one LATENT output
expect(nodeV1.type).toBe('KSampler')
expect(handleV2.type).toBe('KSampler')
expect(nodeV1.outputs.length).toBe(1)
expect(worldV2.nodes.get('node-42')!.outputs.length).toBe(1)
})
})
})

View File

@@ -1,544 +0,0 @@
// 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)
//
// Phase A: Synthetic mock tests for v1 contract behavior.
// Phase B: Real LiteGraph prototype wiring.
import { describe, expect, it } from 'vitest'
// ── Synthetic types ──────────────────────────────────────────────────────────
interface MockLink {
id: number
origin_id: number
origin_slot: number
target_id: number
target_slot: number
}
interface MockSlot {
name: string
type: string
link: number | null
}
interface MockNode {
id: number
type: string
inputs: MockSlot[]
outputs: MockSlot[]
onConnectionsChange?: (
side: number,
slot: number,
connect: boolean,
link: MockLink | null,
ioSlot: MockSlot
) => void
}
interface MockGraph {
links: Map<number, MockLink>
add(node: MockNode): void
getNodeById(id: number): MockNode | undefined
_createLink(
srcNode: MockNode,
srcSlot: number,
dstNode: MockNode,
dstSlot: number
): MockLink | null
_removeLink(linkId: number): void
}
// ── Synthetic implementations ────────────────────────────────────────────────
function createMockGraph(): MockGraph {
const nodes = new Map<number, MockNode>()
const links = new Map<number, MockLink>()
let nextLinkId = 1
return {
links,
add(node: MockNode) {
nodes.set(node.id, node)
},
getNodeById(id: number) {
return nodes.get(id)
},
_createLink(srcNode, srcSlot, dstNode, dstSlot) {
const srcSlotObj = srcNode.outputs[srcSlot]
const dstSlotObj = dstNode.inputs[dstSlot]
if (!srcSlotObj || !dstSlotObj) return null
// Type compatibility check (simplified)
if (
srcSlotObj.type !== dstSlotObj.type &&
srcSlotObj.type !== '*' &&
dstSlotObj.type !== '*'
) {
return null
}
// Remove existing link on target input if any
if (dstSlotObj.link !== null) {
this._removeLink(dstSlotObj.link)
}
const link: MockLink = {
id: nextLinkId++,
origin_id: srcNode.id,
origin_slot: srcSlot,
target_id: dstNode.id,
target_slot: dstSlot
}
links.set(link.id, link)
dstSlotObj.link = link.id
return link
},
_removeLink(linkId) {
const link = links.get(linkId)
if (!link) return
const srcNode = nodes.get(link.origin_id)
const dstNode = nodes.get(link.target_id)
if (dstNode) {
const dstSlot = dstNode.inputs[link.target_slot]
if (dstSlot && dstSlot.link === linkId) {
dstSlot.link = null
}
}
links.delete(linkId)
}
}
}
interface MockNodeWithMethods extends MockNode {
connect: (
srcSlot: number,
targetNode: MockNodeWithMethods,
dstSlot: number,
graph: MockGraph
) => MockLink | null
disconnectInput: (slot: number, graph: MockGraph) => void
}
function createMockNode(
id: number,
type: string,
inputs: Array<{ name: string; type: string }>,
outputs: Array<{ name: string; type: string }>
): MockNodeWithMethods {
const node: MockNodeWithMethods = {
id,
type,
inputs: inputs.map((i) => ({ ...i, link: null })),
outputs: outputs.map((o) => ({ ...o, link: null })),
onConnectionsChange: undefined,
connect(
srcSlot: number,
targetNode: MockNodeWithMethods,
dstSlot: number,
graph: MockGraph
) {
const link = graph._createLink(node, srcSlot, targetNode, dstSlot)
if (link) {
// Fire onConnectionsChange on source node (output side, side=2)
if (node.onConnectionsChange) {
node.onConnectionsChange(
2,
srcSlot,
true,
link,
node.outputs[srcSlot]
)
}
// Fire onConnectionsChange on target node (input side, side=1)
if (targetNode.onConnectionsChange) {
targetNode.onConnectionsChange(
1,
dstSlot,
true,
link,
targetNode.inputs[dstSlot]
)
}
}
return link
},
disconnectInput(slot: number, graph: MockGraph) {
const slotObj = node.inputs[slot]
if (!slotObj || slotObj.link === null) return
const link = graph.links.get(slotObj.link)
if (!link) return
const srcNode = graph.getNodeById(link.origin_id) as
| MockNodeWithMethods
| undefined
graph._removeLink(slotObj.link)
// Fire onConnectionsChange on target (this node, input side)
if (node.onConnectionsChange) {
node.onConnectionsChange(1, slot, false, null, slotObj)
}
// Fire onConnectionsChange on source node (output side)
if (srcNode?.onConnectionsChange) {
srcNode.onConnectionsChange(
2,
link.origin_slot,
false,
null,
srcNode.outputs[link.origin_slot]
)
}
}
}
return node
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('BC.08 v1 contract — programmatic linking', () => {
describe('S10.D2 — node.connect(srcSlot, targetNode, dstSlot)', () => {
it('node.connect(srcSlot, targetNode, dstSlot) creates a link between the source output slot and the target input slot', () => {
const graph = createMockGraph()
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
const link = srcNode.connect(0, dstNode, 0, graph)
expect(link).not.toBeNull()
expect(link!.origin_id).toBe(1)
expect(link!.origin_slot).toBe(0)
expect(link!.target_id).toBe(2)
expect(link!.target_slot).toBe(0)
})
it('connect() returns the newly created link object with a stable numeric id', () => {
const graph = createMockGraph()
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
const link1 = srcNode.connect(0, dstNode, 0, graph)
expect(link1).not.toBeNull()
expect(typeof link1!.id).toBe('number')
expect(link1!.id).toBeGreaterThan(0)
// Second link gets next ID
const dstNode2 = createMockNode(
3,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(dstNode2)
const link2 = srcNode.connect(0, dstNode2, 0, graph)
expect(link2!.id).toBe(link1!.id + 1)
})
it('connect() on an already-occupied input slot replaces the existing link without leaving a dangling reference', () => {
const graph = createMockGraph()
const srcNode1 = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const srcNode2 = createMockNode(
2,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
3,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode1)
graph.add(srcNode2)
graph.add(dstNode)
const link1 = srcNode1.connect(0, dstNode, 0, graph)
expect(link1).not.toBeNull()
expect(dstNode.inputs[0].link).toBe(link1!.id)
// Replace with a new connection
const link2 = srcNode2.connect(0, dstNode, 0, graph)
expect(link2).not.toBeNull()
expect(dstNode.inputs[0].link).toBe(link2!.id)
// Old link should be removed from graph
expect(graph.links.has(link1!.id)).toBe(false)
expect(graph.links.has(link2!.id)).toBe(true)
})
it('connect() with an out-of-bounds slot index returns null', () => {
const graph = createMockGraph()
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
// Out-of-bounds source slot
expect(srcNode.connect(99, dstNode, 0, graph)).toBeNull()
// Out-of-bounds target slot
expect(srcNode.connect(0, dstNode, 99, graph)).toBeNull()
// Graph unchanged
expect(graph.links.size).toBe(0)
})
it('connect() with a type-incompatible slot pair is rejected and returns null without modifying the graph', () => {
const graph = createMockGraph()
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'SaveImage',
[{ name: 'images', type: 'IMAGE' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
const initialLinkCount = graph.links.size
const link = srcNode.connect(0, dstNode, 0, graph)
expect(link).toBeNull()
expect(graph.links.size).toBe(initialLinkCount)
expect(dstNode.inputs[0].link).toBeNull()
})
it('onConnectionsChange fires on both the source and target node after a successful connect() call', () => {
const graph = createMockGraph()
const srcCalls: Array<{ side: number; slot: number; connect: boolean }> =
[]
const dstCalls: Array<{ side: number; slot: number; connect: boolean }> =
[]
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
// Set handlers before connect
srcNode.onConnectionsChange = (side, slot, connect) => {
srcCalls.push({ side, slot, connect })
}
dstNode.onConnectionsChange = (side, slot, connect) => {
dstCalls.push({ side, slot, connect })
}
graph.add(srcNode)
graph.add(dstNode)
srcNode.connect(0, dstNode, 0, graph)
expect(srcCalls).toHaveLength(1)
expect(srcCalls[0]).toEqual({ side: 2, slot: 0, connect: true }) // 2 = output side
expect(dstCalls).toHaveLength(1)
expect(dstCalls[0]).toEqual({ side: 1, slot: 0, connect: true }) // 1 = input side
})
})
describe('S10.D2 — node.disconnectInput(slot)', () => {
it('node.disconnectInput(slot) removes the link on the specified input slot and updates both endpoint nodes', () => {
const graph = createMockGraph()
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
const link = srcNode.connect(0, dstNode, 0, graph)
expect(link).not.toBeNull()
expect(graph.links.size).toBe(1)
dstNode.disconnectInput(0, graph)
expect(graph.links.size).toBe(0)
expect(dstNode.inputs[0].link).toBeNull()
})
it('disconnectInput() on an empty slot is a no-op and does not throw', () => {
const graph = createMockGraph()
const dstNode = createMockNode(
1,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(dstNode)
expect(() => dstNode.disconnectInput(0, graph)).not.toThrow()
expect(dstNode.inputs[0].link).toBeNull()
})
it('onConnectionsChange fires on both the source and target node after disconnectInput() removes a link', () => {
const graph = createMockGraph()
const srcCalls: Array<{ side: number; slot: number; connect: boolean }> =
[]
const dstCalls: Array<{ side: number; slot: number; connect: boolean }> =
[]
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
// Connect first (without tracking)
srcNode.connect(0, dstNode, 0, graph)
// Clear any calls from connect, set up tracking for disconnect
srcNode.onConnectionsChange = (side, slot, connect) => {
srcCalls.push({ side, slot, connect })
}
dstNode.onConnectionsChange = (side, slot, connect) => {
dstCalls.push({ side, slot, connect })
}
dstNode.disconnectInput(0, graph)
expect(dstCalls).toHaveLength(1)
expect(dstCalls[0]).toEqual({ side: 1, slot: 0, connect: false })
expect(srcCalls).toHaveLength(1)
expect(srcCalls[0]).toEqual({ side: 2, slot: 0, connect: false })
})
})
describe('S10.D2 — wildcard/any type slot compatibility', () => {
it('connect() succeeds when source slot type is "*" (wildcard)', () => {
const graph = createMockGraph()
const srcNode = createMockNode(
1,
'Reroute',
[],
[{ name: 'output', type: '*' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
const link = srcNode.connect(0, dstNode, 0, graph)
expect(link).not.toBeNull()
})
it('connect() succeeds when target slot type is "*" (wildcard)', () => {
const graph = createMockGraph()
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'Reroute',
[{ name: 'input', type: '*' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
const link = srcNode.connect(0, dstNode, 0, graph)
expect(link).not.toBeNull()
})
})
})

View File

@@ -1,492 +0,0 @@
// 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
//
// Phase A: Synthetic mock tests for v2 contract behavior.
// Phase B: Real ECS World wiring.
import { describe, it, expect } from 'vitest'
// ── Synthetic types mirroring v2 API surface ─────────────────────────────────
interface MockSlot {
name: string
type: string
link: number | null
}
interface MockLink {
id: number
origin_id: string
origin_slot: number
target_id: string
target_slot: number
_invalid?: boolean
}
interface MockWorld {
links: Map<number, MockLink>
nodes: Map<string, MockNodeInternal>
_nextLinkId: number
}
interface MockNodeInternal {
id: string
type: string
inputs: MockSlot[]
outputs: MockSlot[]
connectionListeners: Array<(e: ConnectionChangeEvent) => void>
}
interface ConnectionChangeEvent {
side: 'input' | 'output'
slotIndex: number
connected: boolean
linkId: number | null
}
interface LinkHandle {
readonly id: number
readonly isValid: () => boolean
}
interface NodeHandle {
readonly id: string
readonly type: string
connect(
srcSlot: number,
targetHandle: NodeHandle,
dstSlot: number
): LinkHandle | null
disconnectInput(slotIndex: number): void
on(
event: 'connectionChange',
handler: (e: ConnectionChangeEvent) => void
): () => void
}
// ── Synthetic implementations ────────────────────────────────────────────────
class TypeMismatchError extends Error {
constructor(srcType: string, dstType: string) {
super(`Cannot connect ${srcType} to ${dstType}: type mismatch`)
this.name = 'TypeMismatchError'
}
}
function createMockWorld(): MockWorld {
return {
links: new Map(),
nodes: new Map(),
_nextLinkId: 1
}
}
function createNodeHandle(
world: MockWorld,
id: string,
type: string,
inputs: Array<{ name: string; type: string }>,
outputs: Array<{ name: string; type: string }>
): NodeHandle {
const internal: MockNodeInternal = {
entityId,
type,
inputs: inputs.map((i) => ({ ...i, link: null })),
outputs: outputs.map((o) => ({ ...o, link: null })),
connectionListeners: []
}
world.nodes.set(entityId, internal)
const handle: NodeHandle = {
get entityId() {
return internal.id
},
get type() {
return internal.type
},
connect(
srcSlot: number,
targetHandle: NodeHandle,
dstSlot: number
): LinkHandle | null {
const srcSlotObj = internal.outputs[srcSlot]
const targetInternal = world.nodes.get(targetHandle.id)
if (!targetInternal) return null
const dstSlotObj = targetInternal.inputs[dstSlot]
if (!srcSlotObj || !dstSlotObj) return null
// Type compatibility check
if (
srcSlotObj.type !== dstSlotObj.type &&
srcSlotObj.type !== '*' &&
dstSlotObj.type !== '*'
) {
throw new TypeMismatchError(srcSlotObj.type, dstSlotObj.type)
}
// Remove existing link on target input if any
if (dstSlotObj.link !== null) {
const oldLink = world.links.get(dstSlotObj.link)
if (oldLink) {
oldLink._invalid = true
world.links.delete(dstSlotObj.link)
// Fire connectionChange for disconnect
internal.connectionListeners.forEach((fn) =>
fn({
side: 'output',
slotIndex: srcSlot,
connected: false,
linkId: null
})
)
targetInternal.connectionListeners.forEach((fn) =>
fn({
side: 'input',
slotIndex: dstSlot,
connected: false,
linkId: null
})
)
}
dstSlotObj.link = null
}
// Create new link
const linkId = world._nextLinkId++
const link: MockLink = {
id: linkId,
origin_id: internal.id,
origin_slot: srcSlot,
target_id: targetInternal.id,
target_slot: dstSlot
}
world.links.set(linkId, link)
dstSlotObj.link = linkId
// Fire connectionChange on both handles
internal.connectionListeners.forEach((fn) =>
fn({ side: 'output', slotIndex: srcSlot, connected: true, linkId })
)
targetInternal.connectionListeners.forEach((fn) =>
fn({ side: 'input', slotIndex: dstSlot, connected: true, linkId })
)
return {
get id() {
return linkId
},
isValid() {
const l = world.links.get(linkId)
return l !== undefined && !l._invalid
}
}
},
disconnectInput(slotIndex: number): void {
const slot = internal.inputs[slotIndex]
if (!slot || slot.link === null) return
const link = world.links.get(slot.link)
if (!link) return
const srcNode = world.nodes.get(link.origin_id)
const linkId = slot.link
// Mark link invalid and remove
link._invalid = true
world.links.delete(slot.link)
slot.link = null
// Fire connectionChange on target (this node)
internal.connectionListeners.forEach((fn) =>
fn({ side: 'input', slotIndex, connected: false, linkId: null })
)
// Fire connectionChange on source
if (srcNode) {
srcNode.connectionListeners.forEach((fn) =>
fn({
side: 'output',
slotIndex: link.origin_slot,
connected: false,
linkId: null
})
)
}
},
on(
event: 'connectionChange',
handler: (e: ConnectionChangeEvent) => void
): () => void {
if (event !== 'connectionChange')
throw new Error(`Unknown event: ${event}`)
internal.connectionListeners.push(handler)
return () => {
const idx = internal.connectionListeners.indexOf(handler)
if (idx !== -1) internal.connectionListeners.splice(idx, 1)
}
}
}
return handle
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('BC.08 v2 contract — programmatic linking', () => {
describe('NodeHandle.connect(slotIndex, targetHandle, dstSlot) — create links', () => {
it('NodeHandle.connect(slotIndex, targetHandle, dstSlot) creates a link between the source output slot and the target input slot', () => {
const world = createMockWorld()
const srcHandle = createNodeHandle(
world,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstHandle = createNodeHandle(
world,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
const linkHandle = srcHandle.connect(0, dstHandle, 0)
expect(linkHandle).not.toBeNull()
expect(world.links.size).toBe(1)
const link = world.links.get(linkHandle!.id)
expect(link?.origin_id).toBe('node-1')
expect(link?.origin_slot).toBe(0)
expect(link?.target_id).toBe('node-2')
expect(link?.target_slot).toBe(0)
})
it('connect() returns a LinkHandle with a stable id that matches the underlying graph link id', () => {
const world = createMockWorld()
const srcHandle = createNodeHandle(
world,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstHandle = createNodeHandle(
world,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
const linkHandle1 = srcHandle.connect(0, dstHandle, 0)
expect(linkHandle1).not.toBeNull()
expect(typeof linkHandle1!.id).toBe('number')
expect(linkHandle1!.id).toBeGreaterThan(0)
expect(linkHandle1!.isValid()).toBe(true)
// Second connect to different node gets next ID
const dstHandle2 = createNodeHandle(
world,
'node-3',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
const linkHandle2 = srcHandle.connect(0, dstHandle2, 0)
expect(linkHandle2!.id).toBe(linkHandle1!.id + 1)
})
it('connect() on an already-occupied input slot replaces the existing link and the old LinkHandle becomes invalid', () => {
const world = createMockWorld()
const srcHandle1 = createNodeHandle(
world,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const srcHandle2 = createNodeHandle(
world,
'node-2',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstHandle = createNodeHandle(
world,
'node-3',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
const linkHandle1 = srcHandle1.connect(0, dstHandle, 0)
expect(linkHandle1).not.toBeNull()
expect(linkHandle1!.isValid()).toBe(true)
// Replace with a new connection
const linkHandle2 = srcHandle2.connect(0, dstHandle, 0)
expect(linkHandle2).not.toBeNull()
expect(linkHandle2!.isValid()).toBe(true)
// Old link handle should be invalid now
expect(linkHandle1!.isValid()).toBe(false)
expect(world.links.size).toBe(1)
expect(world.links.has(linkHandle2!.id)).toBe(true)
})
it('connect() with a type-incompatible slot pair throws a typed error and leaves the graph unchanged', () => {
const world = createMockWorld()
const srcHandle = createNodeHandle(
world,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstHandle = createNodeHandle(
world,
'node-2',
'SaveImage',
[{ name: 'images', type: 'IMAGE' }],
[]
)
const initialLinkCount = world.links.size
expect(() => srcHandle.connect(0, dstHandle, 0)).toThrow(
TypeMismatchError
)
expect(world.links.size).toBe(initialLinkCount)
})
it("on('connectionChange') fires on both NodeHandles after a successful connect() call", () => {
const world = createMockWorld()
const srcCalls: ConnectionChangeEvent[] = []
const dstCalls: ConnectionChangeEvent[] = []
const srcHandle = createNodeHandle(
world,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstHandle = createNodeHandle(
world,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
srcHandle.on('connectionChange', (e) => srcCalls.push(e))
dstHandle.on('connectionChange', (e) => dstCalls.push(e))
const linkHandle = srcHandle.connect(0, dstHandle, 0)
expect(srcCalls).toHaveLength(1)
expect(srcCalls[0].side).toBe('output')
expect(srcCalls[0].slotIndex).toBe(0)
expect(srcCalls[0].connected).toBe(true)
expect(srcCalls[0].linkId).toBe(linkHandle!.id)
expect(dstCalls).toHaveLength(1)
expect(dstCalls[0].side).toBe('input')
expect(dstCalls[0].slotIndex).toBe(0)
expect(dstCalls[0].connected).toBe(true)
expect(dstCalls[0].linkId).toBe(linkHandle!.id)
})
})
describe('NodeHandle.disconnectInput(slotIndex) — remove links', () => {
it('NodeHandle.disconnectInput(slotIndex) removes the link on the specified input slot and the returned LinkHandle becomes invalid', () => {
const world = createMockWorld()
const srcHandle = createNodeHandle(
world,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstHandle = createNodeHandle(
world,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
const linkHandle = srcHandle.connect(0, dstHandle, 0)
expect(linkHandle).not.toBeNull()
expect(linkHandle!.isValid()).toBe(true)
expect(world.links.size).toBe(1)
dstHandle.disconnectInput(0)
expect(world.links.size).toBe(0)
expect(linkHandle!.isValid()).toBe(false)
})
it('disconnectInput() on an empty slot is a no-op and does not throw', () => {
const world = createMockWorld()
const dstHandle = createNodeHandle(
world,
'node-1',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
expect(() => dstHandle.disconnectInput(0)).not.toThrow()
expect(world.links.size).toBe(0)
})
it("on('connectionChange') fires on both source and target NodeHandles after disconnectInput() removes a link", () => {
const world = createMockWorld()
const srcCalls: ConnectionChangeEvent[] = []
const dstCalls: ConnectionChangeEvent[] = []
const srcHandle = createNodeHandle(
world,
'node-1',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstHandle = createNodeHandle(
world,
'node-2',
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
// Connect first (without tracking)
srcHandle.connect(0, dstHandle, 0)
// Set up tracking for disconnect
srcHandle.on('connectionChange', (e) => srcCalls.push(e))
dstHandle.on('connectionChange', (e) => dstCalls.push(e))
dstHandle.disconnectInput(0)
expect(dstCalls).toHaveLength(1)
expect(dstCalls[0].side).toBe('input')
expect(dstCalls[0].slotIndex).toBe(0)
expect(dstCalls[0].connected).toBe(false)
expect(srcCalls).toHaveLength(1)
expect(srcCalls[0].side).toBe('output')
expect(srcCalls[0].slotIndex).toBe(0)
expect(srcCalls[0].connected).toBe(false)
})
})
})

View File

@@ -1,226 +0,0 @@
// 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 NodeHandle slot mutation API (not yet on surface — see gap below)
//
// Phase A findings:
// NodeHandle has inputs()/outputs() (read-only). Slot mutation methods
// (addInput/removeInput/addOutput/removeOutput) are NOT on NodeHandle yet.
// This file tests:
// (a) v1 LGraphNode-style slot mutation shape (documenting the pattern)
// (b) v2 read-surface parity for existing slots
// (c) gap documentation for mutation equivalence (Phase B)
//
// I-TF.8.C2 — BC.09 migration wired assertions.
import { describe, expect, it } from 'vitest'
import type { SlotInfo, NodeEntityId, SlotEntityId } from '@/extension-api/node'
// ── V1 LGraphNode slot shim ───────────────────────────────────────────────────
// Models the v1 pattern: node.addInput(name, type) appends to node.inputs array;
// node.addOutput(name, type) appends to node.outputs array.
// setSize([w, h]) is manual after slot mutation.
interface V1Slot {
name: string
type: string
}
function createV1Node(type = 'TestNode') {
const inputs: V1Slot[] = []
const outputs: V1Slot[] = []
let size: [number, number] = [200, 100]
const BASE_ROW_HEIGHT = 24
return {
type,
get inputs() {
return inputs
},
get outputs() {
return outputs
},
get size() {
return size
},
addInput(name: string, slotType: string) {
inputs.push({ name, type: slotType })
},
addOutput(name: string, slotType: string) {
outputs.push({ name, type: slotType })
},
removeInput(index: number) {
inputs.splice(index, 1)
},
removeOutput(index: number) {
outputs.splice(index, 1)
},
setSize(s: [number, number]) {
size = s
},
computeSize(): [number, number] {
const rows = Math.max(inputs.length, outputs.length)
return [200, Math.max(100, rows * BASE_ROW_HEIGHT + 40)]
}
}
}
// ── V2 read surface shim ──────────────────────────────────────────────────────
// Minimal model of the part of NodeHandle that exists today: inputs()/outputs().
// Mutation is a gap — see Phase B stubs.
function makeSlotInfo(
name: string,
type: string,
direction: 'input' | 'output'
): SlotInfo {
return {
id: ((Math.random() * 1e9) | 0) as unknown as unknown as SlotEntityId,
name,
type,
direction,
nodeId: 1 as unknown as unknown as NodeEntityId
}
}
function createV2ReadSurface(
initialInputs: SlotInfo[],
initialOutputs: SlotInfo[]
) {
const inputs = [...initialInputs]
const outputs = [...initialOutputs]
return {
inputs: () => inputs as readonly SlotInfo[],
outputs: () => outputs as readonly SlotInfo[]
}
}
// ── Wired migration tests (Phase A — read surface) ────────────────────────────
describe('BC.09 migration — dynamic slot and output mutation', () => {
describe('v1 slot mutation shape documentation (S10.D1)', () => {
it('v1 node.addInput(name, type) appends a slot at the end of node.inputs', () => {
const node = createV1Node()
expect(node.inputs).toHaveLength(0)
node.addInput('image', 'IMAGE')
node.addInput('mask', 'MASK')
expect(node.inputs).toHaveLength(2)
expect(node.inputs[0]).toEqual({ name: 'image', type: 'IMAGE' })
expect(node.inputs[1]).toEqual({ name: 'mask', type: 'MASK' })
})
it('v1 node.addOutput(name, type) appends a slot at the end of node.outputs (S10.D3)', () => {
const node = createV1Node()
node.addOutput('LATENT', 'LATENT')
node.addOutput('IMAGE', 'IMAGE')
expect(node.outputs).toHaveLength(2)
expect(node.outputs[0].name).toBe('LATENT')
expect(node.outputs[1].name).toBe('IMAGE')
})
it('v1 removeInput(index) splices by position — order matters', () => {
const node = createV1Node()
node.addInput('a', 'IMAGE')
node.addInput('b', 'LATENT')
node.addInput('c', 'MASK')
node.removeInput(1) // remove 'b' by position
expect(node.inputs).toHaveLength(2)
expect(node.inputs[0].name).toBe('a')
expect(node.inputs[1].name).toBe('c')
})
it('v1 requires manual setSize after addInput to avoid slot overlap', () => {
const node = createV1Node()
const initialSize = node.size[1]
node.addInput('extra', 'IMAGE')
// Without setSize, height is unchanged — this is the v1 footgun
expect(node.size[1]).toBe(initialSize)
// Manual fix: call computeSize + setSize
node.setSize(node.computeSize())
expect(node.size[1]).toBeGreaterThanOrEqual(initialSize)
})
})
describe('v2 read surface parity — inputs() / outputs() shape', () => {
it('v2 inputs() returns the same count as v1 node.inputs after equivalent setup', () => {
// v1 path
const v1 = createV1Node()
v1.addInput('image', 'IMAGE')
v1.addInput('mask', 'MASK')
// v2 path: pre-populated (mutation API gap — see Phase B)
const v2 = createV2ReadSurface(
[
makeSlotInfo('image', 'IMAGE', 'input'),
makeSlotInfo('mask', 'MASK', 'input')
],
[]
)
expect(v2.inputs()).toHaveLength(v1.inputs.length)
expect(v2.inputs()).toHaveLength(2)
})
it('v2 outputs() returns the same count as v1 node.outputs after equivalent setup', () => {
const v1 = createV1Node()
v1.addOutput('LATENT', 'LATENT')
const v2 = createV2ReadSurface(
[],
[makeSlotInfo('LATENT', 'LATENT', 'output')]
)
expect(v2.outputs()).toHaveLength(v1.outputs.length)
})
it('v2 SlotInfo direction field distinguishes inputs from outputs (v1 relies on array membership)', () => {
const v2 = createV2ReadSurface(
[makeSlotInfo('image', 'IMAGE', 'input')],
[makeSlotInfo('LATENT', 'LATENT', 'output')]
)
const allInputs = v2.inputs()
const allOutputs = v2.outputs()
for (const s of allInputs) expect(s.direction).toBe('input')
for (const s of allOutputs) expect(s.direction).toBe('output')
})
it('v2 SlotInfo.name is stable identity (v1 used positional index — fragile)', () => {
const v2 = createV2ReadSurface(
[
makeSlotInfo('image', 'IMAGE', 'input'),
makeSlotInfo('mask', 'MASK', 'input')
],
[]
)
// Name-based access is safe even if order changes in future
const byName = (name: string) => v2.inputs().find((s) => s.name === name)
expect(byName('image')?.type).toBe('IMAGE')
expect(byName('mask')?.type).toBe('MASK')
})
})
describe('[gap] Slot mutation migration — Phase B required', () => {
it.todo(
'[gap] v2 NodeHandle.addInput({ name, type }) equivalent to v1 node.addInput(name, type) — ' +
'addInput/removeInput not yet on NodeHandle surface (src/extension-api/node.ts). Phase B gap.'
)
it.todo(
'[gap] v2 NodeHandle.removeInput(name) equivalent to v1 node.removeInput(index) — name-based vs positional. Phase B gap.'
)
it.todo('[gap] v2 addOutput / removeOutput equivalents. Phase B gap.')
it.todo(
'[gap] v2 auto-reflow eliminates the need for v1 setSize(computeSize()) after slot mutation. Phase B gap.'
)
})
})

View File

@@ -1,201 +0,0 @@
// 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, expect } from 'vitest'
type Slot = { name: string; type: string; link?: number | null }
type OutputSlot = { name: string; type: string; links?: number[] }
function makeNode() {
const inputs: Slot[] = []
const outputs: OutputSlot[] = []
const size: [number, number] = [200, 100]
return {
inputs,
outputs,
size,
addInput(name: string, type: string) {
inputs.push({ name, type, link: null })
},
removeInput(slot: number) {
inputs.splice(slot, 1)
},
addOutput(name: string, type: string) {
outputs.push({ name, type, links: [] })
},
removeOutput(slot: number) {
outputs.splice(slot, 1)
},
setSize(s: [number, number]) {
size[0] = s[0]
size[1] = s[1]
},
computeSize(): [number, number] {
const slotHeight = 20
const rows = Math.max(inputs.length, outputs.length, 1)
return [size[0], rows * slotHeight + 40]
}
}
}
describe('BC.09 v1 contract — dynamic slot and output mutation', () => {
describe('S10.D1 — addInput / removeInput', () => {
it('node.addInput(name, type) appends a new input slot to node.inputs and increments node.inputs.length', () => {
const node = makeNode()
expect(node.inputs).toHaveLength(0)
node.addInput('latent', 'LATENT')
expect(node.inputs).toHaveLength(1)
expect(node.inputs[0].name).toBe('latent')
expect(node.inputs[0].type).toBe('LATENT')
})
it('node.removeInput(slot) removes the slot at the given index and shifts subsequent slots down by one', () => {
const node = makeNode()
node.addInput('a', 'INT')
node.addInput('b', 'FLOAT')
node.addInput('c', 'STRING')
// Remove middle slot
node.removeInput(1)
expect(node.inputs).toHaveLength(2)
expect(node.inputs[0].name).toBe('a')
expect(node.inputs[1].name).toBe('c')
})
it('removing an input slot that has an active link also removes the corresponding link from the graph', () => {
const graph = {
links: new Map<
number,
{ id: number; target_id: number; target_slot: number }
>()
}
const node = {
id: 10,
inputs: [{ name: 'img', type: 'IMAGE', link: 99 }] as Slot[]
}
graph.links.set(99, { id: 99, target_id: 10, target_slot: 0 })
// v1 pattern: remove slot and clean up the link
const removedLink = node.inputs[0].link
node.inputs.splice(0, 1)
if (removedLink !== null && removedLink !== undefined) {
graph.links.delete(removedLink)
}
expect(node.inputs).toHaveLength(0)
expect(graph.links.has(99)).toBe(false)
})
it('addInput with a duplicate name appends a second slot without error (v1 allows duplicates)', () => {
const node = makeNode()
node.addInput('image', 'IMAGE')
node.addInput('image', 'IMAGE')
expect(node.inputs).toHaveLength(2)
expect(node.inputs[0].name).toBe('image')
expect(node.inputs[1].name).toBe('image')
})
})
describe('S10.D3 — addOutput / removeOutput', () => {
it('node.addOutput(name, type) appends a new output slot to node.outputs and increments node.outputs.length', () => {
const node = makeNode()
node.addOutput('IMAGE', 'IMAGE')
expect(node.outputs).toHaveLength(1)
expect(node.outputs[0].name).toBe('IMAGE')
expect(node.outputs[0].type).toBe('IMAGE')
})
it('node.removeOutput(slot) removes the output slot and detaches all outgoing links on that slot', () => {
const graph = { links: new Map<number, unknown>() }
const node = {
outputs: [
{ name: 'IMAGE', type: 'IMAGE', links: [5, 6] },
{ name: 'MASK', type: 'MASK', links: [] }
] as OutputSlot[]
}
graph.links.set(5, {})
graph.links.set(6, {})
// v1 pattern: clear outgoing links, then splice
const slot = node.outputs[0]
for (const linkId of slot.links ?? []) {
graph.links.delete(linkId)
}
node.outputs.splice(0, 1)
expect(node.outputs).toHaveLength(1)
expect(node.outputs[0].name).toBe('MASK')
expect(graph.links.has(5)).toBe(false)
expect(graph.links.has(6)).toBe(false)
})
it('removing an output slot does not affect links on other output slots of the same node', () => {
const graph = { links: new Map<number, unknown>() }
const node = {
outputs: [
{ name: 'A', type: 'INT', links: [1] },
{ name: 'B', type: 'INT', links: [2, 3] }
] as OutputSlot[]
}
graph.links.set(1, {})
graph.links.set(2, {})
graph.links.set(3, {})
// Remove first output slot only
for (const linkId of node.outputs[0].links ?? []) {
graph.links.delete(linkId)
}
node.outputs.splice(0, 1)
expect(node.outputs).toHaveLength(1)
expect(graph.links.has(1)).toBe(false)
expect(graph.links.has(2)).toBe(true)
expect(graph.links.has(3)).toBe(true)
})
})
describe('S15.OS1 — computeSize / setSize reflow', () => {
it('node.setSize([w, h]) updates node.size to the provided dimensions immediately', () => {
const node = makeNode()
node.setSize([350, 220])
expect(node.size[0]).toBe(350)
expect(node.size[1]).toBe(220)
})
it('addInput/addOutput followed by node.setSize([...node.computeSize()]) produces a node tall enough to display all slots without overlap', () => {
const node = makeNode()
node.addInput('a', 'INT')
node.addInput('b', 'FLOAT')
node.addInput('c', 'STRING')
node.addOutput('result', 'INT')
const computed = node.computeSize()
node.setSize([...computed])
// 3 input rows × 20px + 40px padding = 100px minimum
expect(node.size[1]).toBeGreaterThanOrEqual(3 * 20)
})
it('setSize does not trigger a canvas redraw synchronously; redraw occurs on the next animation frame', () => {
const drawCalls: string[] = []
const node = makeNode()
// Simulate the canvas draw loop — setSize only mutates size[], not draw
const mockCanvas = {
draw() {
drawCalls.push('draw')
}
}
node.setSize([400, 300])
// Canvas draw was not called as part of setSize
expect(drawCalls).toHaveLength(0)
// Only when the canvas loop runs does it draw
mockCanvas.draw()
expect(drawCalls).toHaveLength(1)
})
})
})

View File

@@ -1,216 +0,0 @@
// 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
//
// Phase A findings:
// NodeHandle exposes inputs() and outputs() as read-only slot arrays (stable).
// Slot MUTATION (addInput/removeInput/addOutput/removeOutput) is NOT yet on the
// NodeHandle surface — this is a documented gap for Phase B.
// See: src/extension-api/node.ts — no addInput/removeInput methods present.
//
// Tests here prove the read surface contract that IS available today.
// Mutation and auto-reflow cases are in the Phase B block at the bottom.
import { describe, expect, it } from 'vitest'
import type { NodeHandle, SlotInfo } from '@/extension-api/node'
// ── Synthetic NodeHandle stub ─────────────────────────────────────────────────
// Minimal implementation of the NodeHandle slot surface for Phase A assertions.
function makeSlotInfo(overrides: Partial<SlotInfo> = {}): SlotInfo {
return {
id: 'slot:1' as SlotInfo['id'],
name: 'input_0',
type: 'LATENT',
direction: 'input',
nodeId: 'node:10' as SlotInfo['nodeId'],
...overrides
}
}
function makeNodeHandleWithSlots(
inputs: SlotInfo[],
outputs: SlotInfo[]
): Pick<NodeHandle, 'inputs' | 'outputs'> {
return {
inputs: () => inputs,
outputs: () => outputs
}
}
// ── Wired assertions (Phase A — read surface) ─────────────────────────────────
describe('BC.09 v2 contract — dynamic slot and output mutation', () => {
describe('NodeHandle.inputs() — read-only slot array shape', () => {
it('inputs() returns a readonly array of SlotInfo objects', () => {
const slots = [
makeSlotInfo({ name: 'image', type: 'IMAGE', direction: 'input' }),
makeSlotInfo({
name: 'mask',
type: 'MASK',
direction: 'input',
id: 'slot:2' as SlotInfo['id']
})
]
const handle = makeNodeHandleWithSlots(slots, [])
const result = handle.inputs()
expect(result).toHaveLength(2)
expect(result[0].name).toBe('image')
expect(result[0].type).toBe('IMAGE')
expect(result[0].direction).toBe('input')
})
it('inputs() returns an empty array when the node has no input slots', () => {
const handle = makeNodeHandleWithSlots([], [])
expect(handle.inputs()).toHaveLength(0)
expect(Array.isArray(handle.inputs())).toBe(true)
})
it('each SlotInfo has the required fields: entityId, name, type, direction, nodeEntityId', () => {
const nodeId = 'node:42' as SlotInfo['nodeId']
const slot = makeSlotInfo({
name: 'latent',
type: 'LATENT',
nodeId: nodeId
})
const handle = makeNodeHandleWithSlots([slot], [])
const [s] = handle.inputs()
expect(s).toHaveProperty('entityId')
expect(s).toHaveProperty('name', 'latent')
expect(s).toHaveProperty('type', 'LATENT')
expect(s).toHaveProperty('direction', 'input')
expect(s).toHaveProperty('nodeEntityId', nodeId)
})
it('direction is always "input" for slots returned by inputs()', () => {
const slots = [
makeSlotInfo({ name: 'a', direction: 'input' }),
makeSlotInfo({
name: 'b',
direction: 'input',
id: 'slot:2' as SlotInfo['id']
})
]
const handle = makeNodeHandleWithSlots(slots, [])
for (const s of handle.inputs()) {
expect(s.direction).toBe('input')
}
})
it('inputs() is stable across repeated calls (same reference contents)', () => {
const slots = [makeSlotInfo({ name: 'x' })]
const handle = makeNodeHandleWithSlots(slots, [])
const first = handle.inputs()
const second = handle.inputs()
expect(first).toHaveLength(second.length)
expect(first[0].name).toBe(second[0].name)
})
})
describe('NodeHandle.outputs() — read-only slot array shape', () => {
it('outputs() returns a readonly array of SlotInfo objects', () => {
const slots = [
makeSlotInfo({ name: 'LATENT', type: 'LATENT', direction: 'output' }),
makeSlotInfo({
name: 'IMAGE',
type: 'IMAGE',
direction: 'output',
id: 'slot:2' as SlotInfo['id']
})
]
const handle = makeNodeHandleWithSlots([], slots)
const result = handle.outputs()
expect(result).toHaveLength(2)
expect(result[0].name).toBe('LATENT')
expect(result[1].name).toBe('IMAGE')
})
it('outputs() returns an empty array when the node has no output slots', () => {
const handle = makeNodeHandleWithSlots([], [])
expect(handle.outputs()).toHaveLength(0)
})
it('direction is always "output" for slots returned by outputs()', () => {
const slots = [
makeSlotInfo({ name: 'out', direction: 'output' }),
makeSlotInfo({
name: 'out2',
direction: 'output',
id: 'slot:2' as SlotInfo['id']
})
]
const handle = makeNodeHandleWithSlots([], slots)
for (const s of handle.outputs()) {
expect(s.direction).toBe('output')
}
})
it('inputs() and outputs() are independent arrays — do not share references', () => {
const shared = makeSlotInfo({ name: 'shared' })
const inSlot = { ...shared, direction: 'input' as const }
const outSlot = {
...shared,
direction: 'output' as const,
id: 'slot:2' as SlotInfo['id']
}
const handle = makeNodeHandleWithSlots([inSlot], [outSlot])
expect(handle.inputs()[0].direction).toBe('input')
expect(handle.outputs()[0].direction).toBe('output')
})
})
describe('[gap] Slot mutation API — not yet on NodeHandle surface', () => {
it.todo(
'[gap] addInput(name, type) — not present on NodeHandle v2 surface; gap documented for Phase B. ' +
'See: src/extension-api/node.ts NodeHandle interface (no addInput method). ' +
'Phase B: add addInput/removeInput/addOutput/removeOutput dispatching CreateSlot/RemoveSlot ECS commands.'
)
it.todo('[gap] removeInput(name) — same gap; Phase B required')
it.todo('[gap] addOutput(name, type) — same gap; Phase B required')
it.todo('[gap] removeOutput(name) — same gap; Phase B required')
})
})
// ── Phase B stubs — ECS dispatch + auto-reflow ────────────────────────────────
describe('BC.09 v2 contract — dynamic slot mutation [Phase B/C]', () => {
describe('addInput / addOutput dispatch', () => {
it.todo(
'NodeHandle.addInput({ name, type }) dispatches CreateInputSlot command and returns a SlotInfo with stable entityId'
)
it.todo(
'NodeHandle.addOutput({ name, type }) dispatches CreateOutputSlot command and the new slot appears in outputs()'
)
it.todo('addInput with a duplicate name throws a typed DuplicateSlotError')
})
describe('removeInput / removeOutput dispatch', () => {
it.todo(
'NodeHandle.removeInput(name) dispatches RemoveInputSlot; slot no longer appears in inputs()'
)
it.todo(
'NodeHandle.removeOutput(name) dispatches RemoveOutputSlot; any links on that slot are detached'
)
it.todo(
'removeInput(name) on a non-existent slot name throws a typed SlotNotFoundError'
)
})
describe('auto-reflow (replaces S15.OS1 manual setSize)', () => {
it.todo(
'after addInput() the node size is automatically reflowed to fit all slots — no manual setSize required'
)
it.todo(
'after removeOutput() the node height shrinks to remove the vacated slot space'
)
it.todo(
'auto-reflow does not trigger a synchronous canvas redraw; redraw occurs on the next animation frame'
)
})
})

View File

@@ -1,248 +0,0 @@
// 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 widget.on('valueChange', fn)
//
// Key migration facts:
// 1. v1 event name: (no named event — direct callback assignment)
// v2 event name: 'valueChange' (NOT 'change')
// 2. v1 payload: positional args (value, app, node, pos, event)
// v2 payload: typed object { newValue, oldValue }
// 3. v1 S2.N14 (node.onWidgetChanged) has no direct v2 equivalent.
// Migration: subscribe per-widget via widget.on('valueChange').
// 4. v1 and v2 listeners operate independently; both fire for the same
// logical change in a mixed-mode (parallel-paths) app (D6 Phase A).
import { shallowRef } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { WidgetValueChangeEvent } from '@/extension-api/widget'
import type { Unsubscribe } from '@/extension-api/events'
// ── Shared mock: one widget object that supports BOTH v1 and v2 subscriptions ─
// Models the parallel-paths Phase A world where both v1 and v2 extensions
// are active on the same widget simultaneously (D6).
interface V1Widget {
name: string
value: unknown
callback?: (value: unknown, app?: unknown, node?: unknown) => void
}
interface MockWidgetHandle {
name: string
getValue<T = unknown>(): T
setValue(value: unknown): void
on(
event: 'valueChange',
handler: (e: WidgetValueChangeEvent<unknown>) => void
): Unsubscribe
}
function createDualWidget(name: string, initial: unknown = '') {
const valueRef = shallowRef(initial)
const v2Listeners: Array<(e: WidgetValueChangeEvent<unknown>) => void> = []
// v1 shape
const v1: V1Widget = { name, value: initial }
// v2 shape
const v2: MockWidgetHandle = {
name,
getValue<T>() {
return valueRef.value as T
},
setValue(newValue: unknown) {
const oldValue = valueRef.value
if (newValue === oldValue) return
valueRef.value = newValue
v1.value = newValue
// Fire v2 listeners
const event: WidgetValueChangeEvent<unknown> = { newValue, oldValue }
for (const fn of v2Listeners) fn(event)
},
on(
_event: 'valueChange',
handler: (e: WidgetValueChangeEvent<unknown>) => void
): Unsubscribe {
v2Listeners.push(handler)
return () => {
const idx = v2Listeners.indexOf(handler)
if (idx !== -1) v2Listeners.splice(idx, 1)
}
}
}
// Simulate LiteGraph calling v1 callback (Phase A: explicit in tests)
function simulateV1Change(newValue: unknown, node?: unknown): void {
const old = v1.value
v1.value = newValue
v1.callback?.(newValue, undefined, node)
// In Phase A the v1 and v2 paths are separate; v2.setValue must be called
// explicitly to trigger v2 listeners. In production (post-Phase B) the
// reactive bridge will do this automatically.
v2.setValue(newValue)
void old
}
return { v1, v2, simulateV1Change }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.10 migration — widget value subscription', () => {
describe("widget.callback → widget.on('valueChange') — payload shape migration (S4.W1)", () => {
it('v1 callback and v2 valueChange handler both fire with the new value for the same interaction', () => {
const { v1, v2, simulateV1Change } = createDualWidget('steps', 20)
const v1Received: unknown[] = []
const v2Received: WidgetValueChangeEvent<unknown>[] = []
v1.callback = (val) => v1Received.push(val)
v2.on('valueChange', (e) => v2Received.push(e))
simulateV1Change(30)
expect(v1Received).toEqual([30])
expect(v2Received).toHaveLength(1)
expect(v2Received[0].newValue).toBe(30)
})
it('v2 payload is { newValue, oldValue } — v1 payload is positional args; both carry the same new value', () => {
const { v1, v2, simulateV1Change } = createDualWidget('cfg', 7)
let v1Value: unknown
let v2Event: WidgetValueChangeEvent<unknown> | undefined
v1.callback = (val) => {
v1Value = val
}
v2.on('valueChange', (e) => {
v2Event = e
})
simulateV1Change(8)
// v1: first positional arg is the new value
expect(v1Value).toBe(8)
// v2: named object with both new and old
expect(v2Event).toEqual({ newValue: 8, oldValue: 7 })
})
it("v2 event is named 'valueChange' — the v1 pattern has no event name (direct callback assign)", () => {
// Documenting the migration: the v2 string literal is 'valueChange', not 'change'.
// Extension authors migrating from v1 must use the correct name.
const { v2 } = createDualWidget('sampler', 'euler')
const handler = vi.fn()
// Correct v2 event name:
v2.on('valueChange', handler)
v2.setValue('dpm')
expect(handler).toHaveBeenCalledOnce()
})
it("v1 chain-patching and v2 on('valueChange') do not interfere: each operates independently", () => {
const { v1, v2, simulateV1Change } = createDualWidget('seed', 0)
const v1Order: string[] = []
const v2Order: string[] = []
// v1: chain-patch
const orig = v1.callback
v1.callback = function (val, a, n) {
v1Order.push('v1-outer')
orig?.call(this, val, a, n)
}
// v2: independent subscription
v2.on('valueChange', () => v2Order.push('v2-listener'))
simulateV1Change(1)
expect(v1Order).toEqual(['v1-outer'])
expect(v2Order).toEqual(['v2-listener'])
})
})
describe("node.onWidgetChanged → per-widget on('valueChange') — S2.N14 migration", () => {
it('v1 onWidgetChanged and v2 per-widget valueChange both fire for the same widget change', () => {
const { v1, v2, simulateV1Change } = createDualWidget('steps', 20)
const v1NodeCalls: Array<{ name: string; value: unknown }> = []
const v2Calls: WidgetValueChangeEvent<unknown>[] = []
const node = {
onWidgetChanged: (name: string, value: unknown) =>
v1NodeCalls.push({ name, value })
}
// v1: node-level subscription (fires at the node level)
v1.callback = (val) => {
node.onWidgetChanged(v1.name, val)
}
// v2: per-widget subscription
v2.on('valueChange', (e) => v2Calls.push(e))
simulateV1Change(30)
expect(v1NodeCalls).toHaveLength(1)
expect(v1NodeCalls[0]).toEqual({ name: 'steps', value: 30 })
expect(v2Calls).toHaveLength(1)
expect(v2Calls[0].newValue).toBe(30)
})
it('v2 migration: observe all widgets on a node via per-widget subscriptions (replaces single onWidgetChanged)', () => {
const stepW = createDualWidget('steps', 20)
const cfgW = createDualWidget('cfg', 7.0)
const nodeChanges: Array<{ name: string; newValue: unknown }> = []
// v2 migration: subscribe individually — no single node-level event
stepW.v2.on('valueChange', (e) =>
nodeChanges.push({ name: 'steps', newValue: e.newValue })
)
cfgW.v2.on('valueChange', (e) =>
nodeChanges.push({ name: 'cfg', newValue: e.newValue })
)
stepW.v2.setValue(25)
cfgW.v2.setValue(8.0)
expect(nodeChanges).toEqual([
{ name: 'steps', newValue: 25 },
{ name: 'cfg', newValue: 8.0 }
])
})
})
describe('scope disposal isolation', () => {
it("disposing one extension's listener does not remove another extension's listener on the same widget", () => {
const { v2 } = createDualWidget('steps', 20)
const ext1 = vi.fn()
const ext2 = vi.fn()
const unsub1 = v2.on('valueChange', ext1)
v2.on('valueChange', ext2)
// Ext1 unsubscribes (scope disposed)
unsub1()
v2.setValue(30)
expect(ext1).not.toHaveBeenCalled()
expect(ext2).toHaveBeenCalledOnce()
})
it('v1 chain-patch survival: removing v2 listener does not break v1 chain', () => {
const { v1, v2, simulateV1Change } = createDualWidget('cfg', 7)
const v1Handler = vi.fn()
const v2Handler = vi.fn()
const origCb = v1.callback
v1.callback = function (val, a, n) {
v1Handler(val)
origCb?.call(this, val, a, n)
}
const unsub = v2.on('valueChange', v2Handler)
unsub() // remove v2 listener only
simulateV1Change(8)
expect(v1Handler).toHaveBeenCalledWith(8) // v1 chain intact
expect(v2Handler).not.toHaveBeenCalled() // v2 removed
})
})
})

View File

@@ -1,217 +0,0 @@
// 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, ...) { ... }
//
// Harness model (Phase A):
// v1 patterns are synthetic — a plain object with .callback and .value.
// Tests call widget.callback(newValue) directly (as LiteGraph would).
// Real LiteGraph invocation requires Phase B eval sandbox.
import { describe, expect, it, vi } from 'vitest'
import { countEvidenceExcerpts, loadEvidenceSnippet } from '../harness'
// ── Minimal v1 widget stub ────────────────────────────────────────────────────
interface V1Widget {
name: string
value: unknown
callback?: (value: unknown, app?: unknown, node?: unknown) => void
}
function createV1Widget(name: string, value: unknown = ''): V1Widget {
return { name, value }
}
// Simulate LiteGraph calling widget.callback when the user changes a value.
function simulateUserChange(
widget: V1Widget,
newValue: unknown,
node?: unknown
): void {
widget.value = newValue
widget.callback?.(newValue, undefined, node)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.10 v1 contract — widget value subscription', () => {
describe('S4.W1 — widget.callback assignment', () => {
it('assigning widget.callback invokes the function with the new value on user interaction', () => {
const widget = createV1Widget('steps', 20)
const handler = vi.fn()
widget.callback = handler
simulateUserChange(widget, 30)
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith(30, undefined, undefined)
})
it('chain-patching preserves the previous callback: saving old ref and calling it at the end', () => {
const widget = createV1Widget('cfg', 7)
const originalCb = vi.fn()
widget.callback = originalCb
// Extension chain-patches: save original, wrap it.
const patchOrder: string[] = []
const origRef = widget.callback
widget.callback = function (value, app, node) {
patchOrder.push('new')
origRef?.call(this, value, app, node)
}
simulateUserChange(widget, 8)
expect(patchOrder).toEqual(['new'])
expect(originalCb).toHaveBeenCalledOnce()
expect(originalCb).toHaveBeenCalledWith(8, undefined, undefined)
})
it('widget.callback receives (value, app, node, pos, event) — first arg is new value', () => {
const widget = createV1Widget('sampler', 'euler')
const received: unknown[] = []
widget.callback = (...args: unknown[]) => received.push(...args)
const fakeApp = { name: 'app' }
const fakeNode = { id: 42 }
widget.value = 'dpm'
widget.callback('dpm', fakeApp, fakeNode)
expect(received[0]).toBe('dpm')
expect(received[1]).toBe(fakeApp)
expect(received[2]).toBe(fakeNode)
})
it('if multiple extensions chain-patch widget.callback, all callbacks fire in last-patched-first order', () => {
const widget = createV1Widget('steps', 10)
const order: string[] = []
// Extension A patches first
const origA = widget.callback
widget.callback = function (v, a, n) {
order.push('A')
origA?.call(this, v, a, n)
}
// Extension B patches second (outermost)
const origB = widget.callback
widget.callback = function (v, a, n) {
order.push('B')
origB?.call(this, v, a, n)
}
simulateUserChange(widget, 20)
// B is outermost (last patched), calls B → A
expect(order).toEqual(['B', 'A'])
})
it('widget.callback is not invoked when the value does not change (LiteGraph does not call callback for no-ops)', () => {
// This tests the harness model: callback is only invoked when the user
// actually changes the value. The harness calls it explicitly on change.
const widget = createV1Widget('seed', 42)
const handler = vi.fn()
widget.callback = handler
// No change — we do NOT call simulateUserChange, so callback should not fire.
expect(handler).not.toHaveBeenCalled()
expect(widget.value).toBe(42)
})
})
describe('S2.N14 — node.onWidgetChanged', () => {
it('node.onWidgetChanged is called with widget name, new value, old value, and widget reference', () => {
const widget = createV1Widget('steps', 20)
const handler = vi.fn()
const node = { onWidgetChanged: handler }
const oldValue = widget.value
simulateUserChange(widget, 30, node)
node.onWidgetChanged('steps', 30, oldValue, widget)
expect(handler).toHaveBeenCalledWith('steps', 30, 20, widget)
})
it('onWidgetChanged fires for any widget on the node, not only those with an explicit callback', () => {
void createV1Widget('steps', 20) // widgetA with callback, not used in test
const widgetB = createV1Widget('cfg', 7)
const handler = vi.fn()
const node = { onWidgetChanged: handler }
// widgetB has no .callback — but node.onWidgetChanged still fires.
const oldB = widgetB.value
widgetB.value = 8
node.onWidgetChanged('cfg', 8, oldB, widgetB)
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith('cfg', 8, 7, widgetB)
})
it('multiple widgets on the same node each trigger onWidgetChanged independently', () => {
const widgets = [
createV1Widget('steps', 20),
createV1Widget('cfg', 7),
createV1Widget('seed', 0)
]
const calls: Array<[string, unknown]> = []
const node = {
onWidgetChanged: (
name: string,
value: unknown,
_oldValue?: unknown,
_widget?: unknown
) => calls.push([name, value])
}
// Simulate changes to all three widgets
for (const w of widgets) {
const oldValue = w.value
const newValue =
typeof w.value === 'number' ? (w.value as number) + 1 : 'changed'
w.value = newValue
node.onWidgetChanged(w.name, newValue, oldValue, w)
}
expect(calls).toHaveLength(3)
expect(calls[0][0]).toBe('steps')
expect(calls[1][0]).toBe('cfg')
expect(calls[2][0]).toBe('seed')
})
})
describe('S4.W1 — evidence excerpts', () => {
it('S4.W1 has at least one evidence excerpt in the database snapshot', () => {
expect(countEvidenceExcerpts('S4.W1')).toBeGreaterThan(0)
})
it('S4.W1 excerpt contains widget callback chain-patching fingerprint', () => {
// Find an excerpt that contains the chain-patch pattern.
// Not all S4.W1 excerpts are chain-patches (some are direct assigns);
// we search across available excerpts for the canonical fingerprint.
const count = countEvidenceExcerpts('S4.W1')
let found = false
for (let i = 0; i < count; i++) {
const snippet = loadEvidenceSnippet('S4.W1', i)
if (/callback|\.call\s*\(this/.test(snippet)) {
found = true
break
}
}
expect(
found,
'Expected at least one S4.W1 excerpt with callback fingerprint'
).toBe(true)
})
it('S2.N14 has at least one evidence excerpt in the database snapshot', () => {
expect(countEvidenceExcerpts('S2.N14')).toBeGreaterThan(0)
})
it('S2.N14 excerpt contains onWidgetChanged fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N14', 0)
expect(snippet).toMatch(/onWidgetChanged/i)
})
})
})

View File

@@ -1,224 +0,0 @@
// 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: widget.on('valueChange', fn) — NOTE: event name is 'valueChange' not 'change'
//
// Harness model:
// createMockWidgetHandle() builds a minimal WidgetHandle-shaped object backed by
// a Vue shallowRef. Calling .setValue(v) updates the ref and notifies all
// 'valueChange' listeners synchronously (same tick). This proves the event
// contract without requiring the full ECS world (Phase B).
//
// S2.N14 note: NodeHandle.on('widgetChanged') does NOT exist in the v2 API.
// The v2 replacement for per-node widget observation is per-widget
// widget.on('valueChange'). Tests below reflect the real API surface.
import { shallowRef } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { WidgetValueChangeEvent } from '@/extension-api/widget'
import type { Unsubscribe } from '@/extension-api/events'
// ── Minimal mock WidgetHandle ─────────────────────────────────────────────────
interface MockWidgetHandle {
name: string
getValue<T = unknown>(): T
setValue(value: unknown): void
on(
event: 'valueChange',
handler: (e: WidgetValueChangeEvent<unknown>) => void
): Unsubscribe
}
function createMockWidgetHandle(
name: string,
initial: unknown = ''
): MockWidgetHandle {
const valueRef = shallowRef(initial)
const listeners: Array<(e: WidgetValueChangeEvent<unknown>) => void> = []
return {
name,
getValue<T>() {
return valueRef.value as T
},
setValue(newValue: unknown) {
const oldValue = valueRef.value
if (newValue === oldValue) return
valueRef.value = newValue
const event: WidgetValueChangeEvent<unknown> = { newValue, oldValue }
for (const fn of listeners) fn(event)
},
on(
_event: 'valueChange',
handler: (e: WidgetValueChangeEvent<unknown>) => void
): Unsubscribe {
listeners.push(handler)
return () => {
const idx = listeners.indexOf(handler)
if (idx !== -1) listeners.splice(idx, 1)
}
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.10 v2 contract — widget value subscription', () => {
describe("widget.on('valueChange', fn) — per-widget subscription (S4.W1 replacement)", () => {
it("on('valueChange') fires with {newValue, oldValue} when setValue is called", () => {
const widget = createMockWidgetHandle('steps', 20)
const handler = vi.fn()
widget.on('valueChange', handler)
widget.setValue(30)
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith({ newValue: 30, oldValue: 20 })
})
it('handler receives the correct oldValue even after multiple sequential changes', () => {
const widget = createMockWidgetHandle('seed', 0)
const received: WidgetValueChangeEvent<unknown>[] = []
widget.on('valueChange', (e) => received.push(e))
widget.setValue(1)
widget.setValue(2)
widget.setValue(3)
expect(received).toHaveLength(3)
expect(received[0]).toEqual({ newValue: 1, oldValue: 0 })
expect(received[1]).toEqual({ newValue: 2, oldValue: 1 })
expect(received[2]).toEqual({ newValue: 3, oldValue: 2 })
})
it('multiple listeners on the same widget are all invoked in registration order', () => {
const widget = createMockWidgetHandle('cfg', 7)
const order: string[] = []
widget.on('valueChange', () => order.push('first'))
widget.on('valueChange', () => order.push('second'))
widget.on('valueChange', () => order.push('third'))
widget.setValue(8)
expect(order).toEqual(['first', 'second', 'third'])
})
it('unsubscribe return value removes the listener; subsequent changes do not invoke it', () => {
const widget = createMockWidgetHandle('sampler', 'euler')
const handler = vi.fn()
const unsubscribe = widget.on('valueChange', handler)
widget.setValue('dpm')
expect(handler).toHaveBeenCalledOnce()
unsubscribe()
widget.setValue('euler_a')
// Still only one call — handler was removed.
expect(handler).toHaveBeenCalledOnce()
})
it('unsubscribing one listener does not affect other listeners on the same widget', () => {
const widget = createMockWidgetHandle('steps', 10)
const removed = vi.fn()
const kept = vi.fn()
const unsub = widget.on('valueChange', removed)
widget.on('valueChange', kept)
unsub()
widget.setValue(20)
expect(removed).not.toHaveBeenCalled()
expect(kept).toHaveBeenCalledOnce()
})
it('handler does not fire when setValue is called with the same value (no-op change)', () => {
const widget = createMockWidgetHandle('denoise', 1.0)
const handler = vi.fn()
widget.on('valueChange', handler)
widget.setValue(1.0) // same value — should not fire
expect(handler).not.toHaveBeenCalled()
})
it('getValue() returns the current value after setValue', () => {
const widget = createMockWidgetHandle('prompt', 'hello')
widget.setValue('world')
expect(widget.getValue()).toBe('world')
})
it('handler fires when setValue is called with a different object reference (shallow comparison)', () => {
const widget = createMockWidgetHandle('data', { x: 1 })
const handler = vi.fn()
widget.on('valueChange', handler)
widget.setValue({ x: 1 }) // different reference, same shape
// shallowRef uses reference equality — different object = fires
expect(handler).toHaveBeenCalledOnce()
})
it('handler fires when setValue is called with a different array reference', () => {
const widget = createMockWidgetHandle('items', [1, 2, 3])
const handler = vi.fn()
widget.on('valueChange', handler)
widget.setValue([1, 2, 3]) // different reference
expect(handler).toHaveBeenCalledOnce()
})
it('handler does not fire when setValue is called with the same object reference', () => {
const obj = { x: 1 }
const widget = createMockWidgetHandle('data', obj)
const handler = vi.fn()
widget.on('valueChange', handler)
widget.setValue(obj) // same reference
expect(handler).not.toHaveBeenCalled()
})
})
describe('v2 API surface notes — S2.N14', () => {
// S2.N14 (onWidgetChanged) has no NodeHandle.on('widgetChanged') equivalent.
// The v2 replacement is per-widget widget.on('valueChange') subscriptions.
// A node-level "any widget changed" event is not in the v2 API surface.
it('all widgets on a node can be independently observed via per-widget subscriptions', () => {
const widgetA = createMockWidgetHandle('steps', 20)
const widgetB = createMockWidgetHandle('cfg', 7.0)
const nodeChanges: string[] = []
// v2: subscribe to each widget individually (replaces onWidgetChanged)
widgetA.on('valueChange', (e) => nodeChanges.push(`steps:${e.newValue}`))
widgetB.on('valueChange', (e) => nodeChanges.push(`cfg:${e.newValue}`))
widgetA.setValue(25)
widgetB.setValue(8.0)
widgetA.setValue(30)
expect(nodeChanges).toEqual(['steps:25', 'cfg:8', 'steps:30'])
})
it('unsubscribing from one widget does not affect observation of sibling widgets', () => {
const widgetA = createMockWidgetHandle('steps', 20)
const widgetB = createMockWidgetHandle('cfg', 7.0)
const handlerA = vi.fn()
const handlerB = vi.fn()
const unsubA = widgetA.on('valueChange', handlerA)
widgetB.on('valueChange', handlerB)
unsubA()
widgetA.setValue(25)
widgetB.setValue(8.0)
expect(handlerA).not.toHaveBeenCalled()
expect(handlerB).toHaveBeenCalledOnce()
})
})
})

View File

@@ -1,385 +0,0 @@
// 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
//
// PARTIALLY AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// - The widget.value→setValue and widget.options→setOption parity blocks
// remain unchanged (these v2 surfaces are valid).
// - The "node.widgets.push/splice → NodeHandle.addWidget" describe block
// is wrapped via `axiomExcluded({...})` (vitest test.fails) because the
// v2 surface no longer exposes addWidget. v1 callers migrate to one of —
// declare in INPUT_TYPES / boxed widget / non-widget UI primitive.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'v2 NodeHandle does not expose addWidget; the v1↔v2 parity scenario this describes is no longer valid.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.migration.test.ts) ──────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
// vi.mock factories are hoisted; keep imported helpers behind arrows so
// the import binding is read lazily at factory invocation time.
vi.mock('@/world/worldInstance', () =>
worldInstanceMockFactory({ mockGetComponent, mockEntitiesWith })
)
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/world/entityIds', () => emptyMockFactory())
vi.mock('@/world/componentKey', () => componentKeyMockFactory())
vi.mock('@/extension-api/node', () => emptyMockFactory())
vi.mock('@/extension-api/widget', () => emptyMockFactory())
vi.mock('@/extension-api/lifecycle', () => emptyMockFactory())
import {
_clearExtensionsForTesting,
_setDispatchImplForTesting,
defineNode,
mountExtensionsForNode,
unmountExtensionsForNode
} from '@/services/extension-api-service'
import type { NodeEntityId } from '@/world/entityIds'
// ── V1 widget shim ────────────────────────────────────────────────────────────
// Minimal replica of v1 widget direct-mutation pattern.
interface V1Widget {
name: string
value: unknown
callback?: ((v: unknown) => void) | undefined
options?: { values: unknown[] }
}
interface V1Node {
widgets: V1Widget[]
}
function createV1Widget(name: string, value: unknown): V1Widget {
return { name, value, callback: undefined }
}
function createV1ComboWidget(
name: string,
value: string,
values: string[]
): V1Widget {
return { name, value, callback: undefined, options: { values } }
}
function createV1Node(widgets: V1Widget[] = []): V1Node {
return { widgets }
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeNodeId(n: number): NodeEntityId {
return `node:graph-uuid-bc11-mig:${n}` as unknown as NodeEntityId
}
function stubNodeType(id: NodeEntityId, comfyClass = 'TestNode') {
mockGetComponent.mockImplementation((eid, key: { name: string }) => {
if (eid !== id) return undefined
if (key.name === 'NodeType') return { type: comfyClass, comfyClass }
return undefined
})
}
const ALL_TEST_IDS = Array.from({ length: 8 }, (_, i) => makeNodeId(i + 1))
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.11 migration — widget imperative state writes', () => {
let dispatchedCommands: Record<string, unknown>[]
beforeEach(() => {
vi.clearAllMocks()
dispatchedCommands = []
_clearExtensionsForTesting()
ALL_TEST_IDS.forEach((id) => unmountExtensionsForNode(id))
_setDispatchImplForTesting((cmd) => {
dispatchedCommands.push(cmd)
if (cmd.type === 'CreateWidget') {
return `widget:graph:${String(cmd.parentNodeId)}:${String(cmd.name)}`
}
return undefined
})
})
afterEach(() => {
_setDispatchImplForTesting(null)
})
describe('widget.value → WidgetHandle.setValue() (S4.W4)', () => {
it('v1 direct assignment and v2 setValue() both record the new value', () => {
// v1: direct property mutation
const v1Widget = createV1Widget('steps', 20)
v1Widget.value = 30
const v1Result = v1Widget.value
// v2: dispatch-based setValue
let v2WidgetId: string | undefined
defineNode({
name: 'bc11.mig.set-value',
nodeCreated(handle) {
const wh = handle.addWidget('INT', 'steps', 20, {})
v2WidgetId = wh.id as string
wh.setValue(30)
}
})
const id = makeNodeId(1)
stubNodeType(id)
mountExtensionsForNode(id)
const setCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetValue' && c.value === 30
) as { widgetId: string; value: unknown } | undefined
// Both recorded value 30; v2 does so via command dispatch
expect(v1Result).toBe(30)
expect(setCmd).toBeDefined()
expect(setCmd?.value).toBe(30)
expect(setCmd?.widgetId).toBe(v2WidgetId)
})
it('v1 direct assignment does not produce a dispatchable record; v2 setValue() always produces one', () => {
// v1: no command dispatch — just a property write
const v1Widget = createV1Widget('cfg', 7.0)
const v1CommandsBefore = dispatchedCommands.length
v1Widget.value = 8.5
const v1CommandsAfter = dispatchedCommands.length
// v1 produces zero dispatch commands
expect(v1CommandsAfter - v1CommandsBefore).toBe(0)
// v2: always dispatches
defineNode({
name: 'bc11.mig.set-value-dispatch',
nodeCreated(handle) {
const wh = handle.addWidget('FLOAT', 'cfg', 7.0, {})
wh.setValue(8.5)
}
})
const id = makeNodeId(2)
stubNodeType(id)
mountExtensionsForNode(id)
const setCmd = dispatchedCommands.find((c) => c.type === 'SetWidgetValue')
expect(setCmd).toBeDefined()
})
})
describe('widget.options.values → WidgetHandle.setOption({ values }) (S4.W5)', () => {
it('v1 options.values mutation and v2 setOption both replace the COMBO option list', () => {
const newValues = ['euler', 'dpm_2', 'lcm']
// v1: direct options mutation
const v1Widget = createV1ComboWidget('sampler', 'euler', [
'euler',
'dpm_2'
])
v1Widget.options!.values = newValues
expect(v1Widget.options!.values).toEqual(newValues)
// v2: setOption dispatch
defineNode({
name: 'bc11.mig.set-options',
nodeCreated(handle) {
const wh = handle.addWidget('COMBO', 'sampler', 'euler', {
values: ['euler', 'dpm_2']
})
wh.setOption('values', newValues)
}
})
const id = makeNodeId(3)
stubNodeType(id)
mountExtensionsForNode(id)
const optCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetOption' && c.key === 'values'
) as { value: unknown } | undefined
expect(optCmd).toBeDefined()
expect(optCmd?.value).toEqual(newValues)
})
it('both v1 and v2 option-set operations are independent per widget', () => {
// v1: two widgets, each with independent options mutation
const v1WidgetA = createV1ComboWidget('schedulerA', 'karras', [
'karras',
'normal'
])
const v1WidgetB = createV1ComboWidget('schedulerB', 'karras', [
'karras',
'normal'
])
v1WidgetA.options!.values = ['karras', 'exponential']
// B is unaffected
expect(v1WidgetB.options!.values).toEqual(['karras', 'normal'])
expect(v1WidgetA.options!.values).toEqual(['karras', 'exponential'])
// v2: same independence via named widget identity
defineNode({
name: 'bc11.mig.option-independence',
nodeCreated(handle) {
const whA = handle.addWidget('COMBO', 'schedulerA', 'karras', {
values: ['karras', 'normal']
})
handle.addWidget('COMBO', 'schedulerB', 'karras', {
values: ['karras', 'normal']
})
whA.setOption('values', ['karras', 'exponential'])
}
})
const id = makeNodeId(4)
stubNodeType(id)
mountExtensionsForNode(id)
const optCmds = dispatchedCommands.filter(
(c) => c.type === 'SetWidgetOption' && c.key === 'values'
)
// Only one setOption dispatch — for whA
expect(optCmds).toHaveLength(1)
})
})
describe('node.widgets.push/splice → NodeHandle.addWidget (S2.N16)', () => {
excluded('v1 push and v2 addWidget both result in a new widget with the expected name', () => {
// v1: push into node.widgets
const v1Node = createV1Node()
const v1NewWidget = createV1Widget('dynamic_lora', '')
v1Node.widgets.push(v1NewWidget)
const v1Names = v1Node.widgets.map((w) => w.name)
// v2: addWidget dispatch
const v2Names: string[] = []
defineNode({
name: 'bc11.mig.add-widget',
nodeCreated(handle) {
const wh = handle.addWidget('STRING', 'dynamic_lora', '', {})
v2Names.push(wh.name)
}
})
const id = makeNodeId(5)
stubNodeType(id)
mountExtensionsForNode(id)
expect(v1Names).toContain('dynamic_lora')
expect(v2Names).toContain('dynamic_lora')
})
excluded('v1 splice by index is position-dependent; v2 addWidget uses name-keyed identity (no drift)', () => {
// v1: positional splice — inserting before 'cfg' bumps 'cfg' index
const v1Node = createV1Node([
createV1Widget('steps', 20),
createV1Widget('cfg', 7.0)
])
// Insert at index 1 — cfg shifts to index 2
v1Node.widgets.splice(1, 0, createV1Widget('new_widget', 0))
expect(v1Node.widgets[2].name).toBe('cfg') // positional drift
expect(v1Node.widgets[1].name).toBe('new_widget')
// v2: addWidget uses name key — 'cfg' remains at key 'cfg' regardless of insertion order
defineNode({
name: 'bc11.mig.no-drift',
nodeCreated(handle) {
handle.addWidget('INT', 'steps', 20, {})
handle.addWidget('INT', 'new_widget', 0, {})
handle.addWidget('FLOAT', 'cfg', 7.0, {})
}
})
const id = makeNodeId(6)
stubNodeType(id)
mountExtensionsForNode(id)
const names = dispatchedCommands
.filter((c) => c.type === 'CreateWidget')
.map((c) => c.name)
// All three present; order is insertion order but names are stable
expect(names).toContain('cfg')
expect(names).toContain('steps')
expect(names).toContain('new_widget')
})
excluded('v2 addWidget returns a WidgetHandle that can immediately call setValue — no index lookup needed', () => {
defineNode({
name: 'bc11.mig.immediate-set',
nodeCreated(handle) {
const wh = handle.addWidget('INT', 'strength', 0, {})
wh.setValue(100)
}
})
const id = makeNodeId(7)
stubNodeType(id)
mountExtensionsForNode(id)
const setCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetValue' && c.value === 100
)
expect(setCmd).toBeDefined()
})
excluded('v1 push requires manual index tracking; v2 addWidget returns handle directly — no index bookkeeping', () => {
// v1: to get the widget back after push, you track the index
const v1Node = createV1Node()
v1Node.widgets.push(createV1Widget('added', ''))
const v1ByIndex = v1Node.widgets[0] // must track index manually
expect(v1ByIndex.name).toBe('added')
// v2: handle returned from addWidget — no index
let whName: string | undefined
defineNode({
name: 'bc11.mig.handle-returned',
nodeCreated(handle) {
const wh = handle.addWidget('STRING', 'added', '', {})
whName = wh.name // no index needed
}
})
const id = makeNodeId(8)
stubNodeType(id)
mountExtensionsForNode(id)
expect(whName).toBe('added')
})
})
describe('Phase B deferred', () => {
it.todo(
'v1 direct widget.value assignment and v2 setValue() both result in the same displayed value on the canvas after flush (Phase B — requires LiteGraph canvas)'
)
it.todo(
'v2 setOption({ values }) that removes current value causes on("valueChange") with newValue = options[0]; v1 does not auto-fire change (Phase B)'
)
it.todo(
'v1 node.widgets.push requires manual setSize reflow; v2 addWidget performs it automatically — no double-reflow when migrating (Phase B)'
)
})
})

View File

@@ -1,297 +0,0 @@
// 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, expect, it, vi } from 'vitest'
import {
countEvidenceExcerpts,
createMiniComfyApp,
loadEvidenceSnippet,
runV1
} from '../harness'
// ── Minimal v1 widget stubs ───────────────────────────────────────────────────
interface V1Widget {
name: string
value: unknown
callback?: ((v: unknown) => void) | undefined
options?: { values: unknown[] }
}
function createV1Widget(name: string, value: unknown = ''): V1Widget {
return { name, value, callback: undefined }
}
function createV1ComboWidget(
name: string,
value: string,
values: string[]
): V1Widget {
return { name, value, callback: undefined, options: { values } }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.11 v1 contract — widget imperative state writes', () => {
// ── S4.W4 evidence ──────────────────────────────────────────────────────────
describe('S4.W4 — evidence excerpts', () => {
it('S4.W4 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S4.W4')).toBeGreaterThan(0)
})
it('S4.W4 evidence snippet contains widget.value fingerprint', () => {
const count = countEvidenceExcerpts('S4.W4')
let found = false
for (let i = 0; i < count; i++) {
const snippet = loadEvidenceSnippet('S4.W4', i)
if (/widget\.value|\.value\s*=/.test(snippet)) {
found = true
break
}
}
expect(
found,
'Expected at least one S4.W4 excerpt with widget.value fingerprint'
).toBe(true)
})
it('S4.W4 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S4.W4', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
// ── S4.W5 evidence ──────────────────────────────────────────────────────────
describe('S4.W5 — evidence excerpts', () => {
it('S4.W5 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S4.W5')).toBeGreaterThan(0)
})
it('S4.W5 evidence snippet contains options.values or widget.value fingerprint', () => {
const count = countEvidenceExcerpts('S4.W5')
let found = false
for (let i = 0; i < count; i++) {
const snippet = loadEvidenceSnippet('S4.W5', i)
if (/options\.values|\.values\s*=|widget\.value/.test(snippet)) {
found = true
break
}
}
expect(
found,
'Expected at least one S4.W5 excerpt with options.values or widget.value fingerprint'
).toBe(true)
})
it('S4.W5 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S4.W5', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
// ── S2.N16 evidence ─────────────────────────────────────────────────────────
describe('S2.N16 — evidence excerpts', () => {
it('S2.N16 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N16')).toBeGreaterThan(0)
})
it('S2.N16 evidence snippet contains node.widgets or widgets.push fingerprint', () => {
const count = countEvidenceExcerpts('S2.N16')
let found = false
for (let i = 0; i < count; i++) {
const snippet = loadEvidenceSnippet('S2.N16', i)
if (/node\.widgets|widgets\.push|widgets\.splice/.test(snippet)) {
found = true
break
}
}
expect(
found,
'Expected at least one S2.N16 excerpt with node.widgets fingerprint'
).toBe(true)
})
it('S2.N16 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N16', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
// ── S4.W4 synthetic behavior ─────────────────────────────────────────────────
describe('S4.W4 — widget.value direct assignment', () => {
it('reading widget.value after assignment returns the assigned value (immediate read-back)', () => {
const widget: {
name: string
value: unknown
callback: ((v: unknown) => void) | undefined
} = {
name: 'steps',
value: 20 as unknown,
callback: undefined
}
widget.value = 30
expect(widget.value).toBe(30)
})
it('value assignment does NOT trigger widget.callback (contrast with simulateUserChange which does call callback)', () => {
const widget = createV1Widget('steps', 20)
const cb = vi.fn()
widget.callback = cb
widget.value = 30 // direct assignment, no callback fire
expect(cb).not.toHaveBeenCalled()
})
it('assigning a value outside the COMBO options list does not throw', () => {
const comboWidget = createV1ComboWidget('sampler', 'euler', [
'euler',
'dpm'
])
// Value not in options — must not throw
expect(() => {
comboWidget.value = 'unknown_sampler'
}).not.toThrow()
expect(comboWidget.value).toBe('unknown_sampler')
})
})
// ── S4.W5 synthetic behavior ─────────────────────────────────────────────────
describe('S4.W5 — widget.options.values mutation (COMBO options)', () => {
it('assigning widget.options.values = [...] replaces the options list', () => {
const comboWidget = {
name: 'model',
value: 'sd15',
options: { values: ['sd15', 'sdxl'] }
}
comboWidget.options.values = ['flux', 'sd3']
expect(comboWidget.options.values).toEqual(['flux', 'sd3'])
})
it('stale value (absent from new options) persists without auto-reset', () => {
const comboWidget = createV1ComboWidget('model', 'sd15', ['sd15', 'sdxl'])
// Replace options with a list that doesn't include the current value
comboWidget.options!.values = ['flux', 'sd3']
// v1 has no auto-reset: stale value remains
expect(comboWidget.value).toBe('sd15')
})
it('mutation of options.values does not fire widget.callback', () => {
const comboWidget = createV1ComboWidget('model', 'sd15', ['sd15', 'sdxl'])
const cb = vi.fn()
comboWidget.callback = cb
comboWidget.options!.values = ['flux', 'sd3']
expect(cb).not.toHaveBeenCalled()
})
})
// ── S2.N16 synthetic behavior ────────────────────────────────────────────────
describe('S2.N16 — node.widgets array mutation (insert / push)', () => {
it('widgets.push appends a widget and it is immediately in the array', () => {
const node = { widgets: [] as V1Widget[] }
const newWidget = createV1Widget('denoise', 1.0)
node.widgets.push(newWidget)
expect(node.widgets).toHaveLength(1)
expect(node.widgets[0]).toBe(newWidget)
})
it('widgets.splice(i, 0, w) inserts at position i and shifts subsequent widgets', () => {
const w0 = createV1Widget('steps', 20)
const w1 = createV1Widget('cfg', 7)
const node = { widgets: [w0, w1] as V1Widget[] }
const wNew = createV1Widget('denoise', 1.0)
node.widgets.splice(1, 0, wNew)
expect(node.widgets).toHaveLength(3)
expect(node.widgets[0]).toBe(w0)
expect(node.widgets[1]).toBe(wNew)
expect(node.widgets[2]).toBe(w1)
})
it('inserting via splice at position 0 makes the new widget the first element', () => {
const w0 = createV1Widget('steps', 20)
const w1 = createV1Widget('cfg', 7)
const node = { widgets: [w0, w1] as V1Widget[] }
const wFirst = createV1Widget('seed', 0)
node.widgets.splice(0, 0, wFirst)
expect(node.widgets[0]).toBe(wFirst)
expect(node.widgets[1]).toBe(w0)
expect(node.widgets[2]).toBe(w1)
})
it('canvas redraw visibility: node.widgets.push does not update node.size; calling setSize([...computeSize()]) is required to avoid slot overlap', () => {
const node = {
size: [200, 60] as [number, number],
widgets: [] as V1Widget[],
computeSize(): [number, number] {
// 20px per widget row + 40px header
return [this.size[0], this.widgets.length * 20 + 40]
},
setSize(s: [number, number]) {
this.size[0] = s[0]
this.size[1] = s[1]
}
}
const w = createV1Widget('denoise', 1.0)
node.widgets.push(w)
// size has NOT changed yet — push does not resize
expect(node.size[1]).toBe(60)
// After explicit setSize, size reflects new widget count
node.setSize([...node.computeSize()])
expect(node.size[1]).toBe(60) // 1 widget * 20 + 40 = 60
})
it('node size reflow: node.widgets.push does not trigger a canvas redraw without an explicit setDirtyCanvas call', () => {
const drawCalls: string[] = []
const node = {
widgets: [] as V1Widget[],
size: [200, 60] as [number, number]
}
const mockCanvas = {
setDirtyCanvas(foreground: boolean) {
if (foreground) drawCalls.push('dirty')
}
}
node.widgets.push(createV1Widget('denoise', 1.0))
// push alone does not redraw
expect(drawCalls).toHaveLength(0)
// Only after setDirtyCanvas does a redraw get scheduled
mockCanvas.setDirtyCanvas(true)
expect(drawCalls).toHaveLength(1)
})
it('positional drift in widgets_values: inserting a widget via splice causes widgets_values positional drift if not followed by a node size reflow', () => {
// widgets_values is positional: [w0.value, w1.value, w2.value]
const w0 = createV1Widget('steps', 20)
const w1 = createV1Widget('cfg', 7)
const node = { widgets: [w0, w1] as V1Widget[] }
// Before splice: positional order is [steps=20, cfg=7]
const beforeSerialized = node.widgets.map((w) => w.value)
expect(beforeSerialized).toEqual([20, 7])
// Insert a new widget at index 1 — drift: cfg is now at index 2
const wNew = createV1Widget('denoise', 0.9)
node.widgets.splice(1, 0, wNew)
// After splice: positional order is [steps=20, denoise=0.9, cfg=7]
const afterSerialized = node.widgets.map((w) => w.value)
expect(afterSerialized).toEqual([20, 0.9, 7])
// A workflow saved before the splice would try to restore cfg from index 1 (= 0.9 now) — drift
expect(afterSerialized[1]).toBe(0.9) // was cfg=7 before
expect(afterSerialized[2]).toBe(7) // cfg has drifted to index 2
})
})
})

View File

@@ -1,356 +0,0 @@
// 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
//
// PARTIALLY AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// - WidgetHandle.setValue / setHidden / setDisabled / setOption tests
// remain unchanged — these surfaces are valid in v2.
// - The "NodeHandle.addWidget" describe block is wrapped via
// `axiomExcluded({...})` (vitest test.fails) because v2 NodeHandle
// no longer exposes `addWidget`. The tests continue to run as
// regression alarms.
//
// The "compat-floor blast_radius ≥ 2.0 MUST pass before v2 ships"
// doctrine is retired (AXIOMS.md §Axiom-Excluded Test Annotation Policy).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WidgetHandle } from '@/extension-api/widget'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'v2 NodeHandle does not expose addWidget; runtime widget addition is forbidden per A15.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.v2.test.ts) ────────────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
// vi.mock factories are hoisted; keep imported helpers behind arrows so
// the import binding is read lazily at factory invocation time.
vi.mock('@/world/worldInstance', () =>
worldInstanceMockFactory({ mockGetComponent, mockEntitiesWith })
)
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/world/entityIds', () => emptyMockFactory())
vi.mock('@/world/componentKey', () => componentKeyMockFactory())
vi.mock('@/extension-api/node', () => emptyMockFactory())
vi.mock('@/extension-api/widget', () => emptyMockFactory())
vi.mock('@/extension-api/lifecycle', () => emptyMockFactory())
import {
_clearExtensionsForTesting,
_setDispatchImplForTesting,
defineNode,
mountExtensionsForNode,
unmountExtensionsForNode
} from '@/services/extension-api-service'
import type { NodeEntityId } from '@/world/entityIds'
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeNodeId(n: number): NodeEntityId {
return `node:graph-uuid-bc11:${n}` as unknown as NodeEntityId
}
function stubNodeType(id: NodeEntityId, comfyClass = 'TestNode') {
mockGetComponent.mockImplementation((eid, key: { name: string }) => {
if (eid !== id) return undefined
if (key.name === 'NodeType') return { type: comfyClass, comfyClass }
return undefined
})
}
const ALL_TEST_IDS = Array.from({ length: 10 }, (_, i) => makeNodeId(i + 1))
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.11 v2 contract — widget imperative state writes', () => {
let dispatchedCommands: Record<string, unknown>[]
beforeEach(() => {
vi.clearAllMocks()
dispatchedCommands = []
_clearExtensionsForTesting()
ALL_TEST_IDS.forEach((id) => unmountExtensionsForNode(id))
_setDispatchImplForTesting((cmd) => {
dispatchedCommands.push(cmd)
if (cmd.type === 'CreateWidget') {
return `widget:graph:${String(cmd.parentNodeId)}:${String(cmd.name)}`
}
return undefined
})
})
afterEach(() => {
_setDispatchImplForTesting(null)
})
describe('WidgetHandle.setValue(v) — controlled value write (S4.W4)', () => {
it('WidgetHandle.setValue(v) dispatches a SetWidgetValue command with the correct value', () => {
let widgetHandle: WidgetHandle | undefined
defineNode({
name: 'bc11.v2.set-value',
nodeCreated(handle) {
const wh = handle.addWidget('INT', 'steps', 20, {})
widgetHandle = wh
}
})
const id = makeNodeId(1)
stubNodeType(id)
mountExtensionsForNode(id)
widgetHandle!.setValue(42)
const setCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetValue' && c.value === 42
)
expect(setCmd).toBeDefined()
})
it('setValue dispatches with the widgetId matching the created widget', () => {
const capturedWidgetId: string[] = []
defineNode({
name: 'bc11.v2.set-value-id',
nodeCreated(handle) {
const wh = handle.addWidget('FLOAT', 'cfg', 7.0, {})
capturedWidgetId.push(wh.id as string)
wh.setValue(8.5)
}
})
const id = makeNodeId(2)
stubNodeType(id)
mountExtensionsForNode(id)
const setCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetValue'
) as { widgetId: string; value: unknown } | undefined
expect(setCmd).toBeDefined()
expect(setCmd?.widgetId).toBe(capturedWidgetId[0])
expect(setCmd?.value).toBe(8.5)
})
it('successive setValue calls each dispatch a separate SetWidgetValue command', () => {
defineNode({
name: 'bc11.v2.multi-set-value',
nodeCreated(handle) {
const wh = handle.addWidget('INT', 'seed', 0, {})
wh.setValue(1)
wh.setValue(2)
wh.setValue(3)
}
})
const id = makeNodeId(3)
stubNodeType(id)
mountExtensionsForNode(id)
const setCmds = dispatchedCommands.filter(
(c) => c.type === 'SetWidgetValue'
)
expect(setCmds).toHaveLength(3)
expect(setCmds.map((c) => c.value)).toEqual([1, 2, 3])
})
})
describe('WidgetHandle.setHidden / setDisabled — display state writes (S4.W4)', () => {
it('WidgetHandle.setHidden(true) dispatches SetWidgetOption with key "hidden" = true', () => {
defineNode({
name: 'bc11.v2.set-hidden',
nodeCreated(handle) {
const wh = handle.addWidget('BOOLEAN', 'show_advanced', false, {})
wh.setHidden(true)
}
})
const id = makeNodeId(4)
stubNodeType(id)
mountExtensionsForNode(id)
const cmd = dispatchedCommands.find(
(c) =>
c.type === 'SetWidgetOption' && c.key === 'hidden' && c.value === true
)
expect(cmd).toBeDefined()
})
it('WidgetHandle.setDisabled(true) dispatches SetWidgetOption with key "disabled" = true', () => {
defineNode({
name: 'bc11.v2.set-disabled',
nodeCreated(handle) {
const wh = handle.addWidget('STRING', 'lora_name', '', {})
wh.setDisabled(true)
}
})
const id = makeNodeId(5)
stubNodeType(id)
mountExtensionsForNode(id)
const cmd = dispatchedCommands.find(
(c) =>
c.type === 'SetWidgetOption' &&
c.key === 'disabled' &&
c.value === true
)
expect(cmd).toBeDefined()
})
})
describe('WidgetHandle.setOption — COMBO and generic option replacement (S4.W5)', () => {
it('setOption dispatches a SetWidgetOption command with the given key and value', () => {
defineNode({
name: 'bc11.v2.set-option',
nodeCreated(handle) {
const wh = handle.addWidget('COMBO', 'sampler_name', 'euler', {
values: ['euler', 'dpm_2']
})
wh.setOption('values', ['euler', 'dpm_2', 'lcm'])
}
})
const id = makeNodeId(6)
stubNodeType(id)
mountExtensionsForNode(id)
const cmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetOption' && c.key === 'values'
) as { value: unknown[] } | undefined
expect(cmd).toBeDefined()
expect(cmd?.value).toContain('lcm')
})
it('multiple setOption calls each produce separate SetWidgetOption commands', () => {
defineNode({
name: 'bc11.v2.multi-option',
nodeCreated(handle) {
const wh = handle.addWidget('STRING', 'label', '', {})
wh.setOption('placeholder', 'Enter text')
wh.setOption('maxLength', 256)
}
})
const id = makeNodeId(7)
stubNodeType(id)
mountExtensionsForNode(id)
const optCmds = dispatchedCommands.filter(
(c) => c.type === 'SetWidgetOption'
)
const keys = optCmds.map((c) => c.key)
expect(keys).toContain('placeholder')
expect(keys).toContain('maxLength')
})
})
describe('NodeHandle.addWidget — managed widget list mutation (S2.N16)', () => {
excluded('addWidget dispatches a CreateWidget command and returns a handle with the given name', () => {
let handleName: string | undefined
defineNode({
name: 'bc11.v2.add-widget',
nodeCreated(handle) {
const wh = handle.addWidget('INT', 'steps', 20, {})
handleName = wh.name
}
})
const id = makeNodeId(8)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmd = dispatchedCommands.find(
(c) => c.type === 'CreateWidget' && c.name === 'steps'
)
expect(createCmd).toBeDefined()
expect(handleName).toBe('steps')
})
excluded('addWidget for each of two distinct widgets produces two independent CreateWidget commands', () => {
defineNode({
name: 'bc11.v2.add-two-widgets',
nodeCreated(handle) {
handle.addWidget('INT', 'steps', 20, {})
handle.addWidget('FLOAT', 'cfg', 7.0, {})
}
})
const id = makeNodeId(9)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmds = dispatchedCommands.filter(
(c) => c.type === 'CreateWidget'
)
const names = createCmds.map((c) => c.name)
expect(names).toContain('steps')
expect(names).toContain('cfg')
expect(createCmds).toHaveLength(2)
})
excluded('addWidget carries the defaultValue in the CreateWidget command', () => {
defineNode({
name: 'bc11.v2.add-widget-default',
nodeCreated(handle) {
handle.addWidget('INT', 'seed', 42, {})
}
})
const id = makeNodeId(10)
stubNodeType(id)
mountExtensionsForNode(id)
const createCmd = dispatchedCommands.find(
(c) => c.type === 'CreateWidget' && c.name === 'seed'
) as { defaultValue: unknown } | undefined
expect(createCmd?.defaultValue).toBe(42)
})
})
describe('Phase B deferred', () => {
it.todo(
'WidgetHandle.setValue(v) fires the on("valueChange") listeners with {newValue, oldValue} in the same tick (Phase B — requires reactive World)'
)
it.todo(
'WidgetHandle.setOption({ values }) that removes current value triggers on("valueChange") with reset to options[0] (Phase B)'
)
it.todo(
'NodeHandle.addWidget auto-reflows node size and updates widgets_values named map (Phase B — requires ECS node dimensions component)'
)
it.todo(
'NodeHandle.addWidget does not cause widgets_values positional drift because v2 uses a named map rather than a positional array (Phase B)'
)
})
})

View File

@@ -1,124 +0,0 @@
// 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('beforeSerialize') name-based
import { describe, it, expect, expectTypeOf } from 'vitest'
import type {
WidgetHandle,
WidgetBeforeSerializeEvent
} from '@/extension-api/widget'
describe('BC.12 migration — per-widget serialization transform', () => {
describe('API surface difference: positional index removed', () => {
it('v1 serializeValue received (node, index); v2 beforeSerialize event has no index field', () => {
// Type-level proof: WidgetBeforeSerializeEvent has no numeric index property.
type E = WidgetBeforeSerializeEvent
// These keys must NOT exist on the event type.
type HasIndex = 'index' extends keyof E ? true : false
type HasWidgetIndex = 'widgetIndex' extends keyof E ? true : false
const noIndex: HasIndex = false
const noWidgetIndex: HasWidgetIndex = false
expect(noIndex).toBe(false)
expect(noWidgetIndex).toBe(false)
})
it('v2 beforeSerialize event carries context discriminant absent from v1 serializeValue', () => {
type E = WidgetBeforeSerializeEvent
type HasContext = 'context' extends keyof E ? true : false
const hasContext: HasContext = true
expect(hasContext).toBe(true)
// The context field covers all four serialization paths.
expectTypeOf<E['context']>().toEqualTypeOf<
'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
>()
})
it('v2 setSerializedValue replaces the implicit return-value contract of v1 serializeValue', () => {
// v1: `return transformedValue` — the return value was used.
// v2: `event.setSerializedValue(transformedValue)` — explicit override.
type SetFn = WidgetBeforeSerializeEvent['setSerializedValue']
expectTypeOf<SetFn>().toBeFunction()
expectTypeOf<SetFn>().parameter(0).toEqualTypeOf<unknown>()
})
it('v2 skip() replaces v1 options.serialize===false pattern for prompt exclusion', () => {
type SkipFn = WidgetBeforeSerializeEvent['skip']
expectTypeOf<SkipFn>().toBeFunction()
// skip() takes no arguments — not a value return
type Params = Parameters<SkipFn>
expectTypeOf<Params['length']>().toEqualTypeOf<0>()
})
it('v2 WidgetHandle exposes isSerializeEnabled / setSerializeEnabled as first-class fields', () => {
expectTypeOf<WidgetHandle['isSerializeEnabled']>().toBeFunction()
expectTypeOf<WidgetHandle['setSerializeEnabled']>().toBeFunction()
})
})
describe('identity model: name-based vs positional', () => {
it('WidgetHandle.name is a readonly string — the stable identity key replacing positional index', () => {
type NameField = WidgetHandle['name']
expectTypeOf<NameField>().toEqualTypeOf<string>()
})
it('WidgetHandle.id is a branded string — prevents mixing widget IDs with node IDs', () => {
type EntityId = WidgetHandle['id']
// Branded: assignable to string but not plain string (structurally string & { __brand })
type IsString = EntityId extends string ? true : false
const branded: IsString = true
expect(branded).toBe(true)
})
it.todo(
// TODO(Phase B): requires live World + graphToPrompt + slot reorder operation
'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(
// TODO(Phase B): requires live World + multiple on() registrations
"registering on('beforeSerialize') twice does not double-fire; each unsubscribe function removes only the listener it was returned for"
)
})
describe('serialize===false widget compat', () => {
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline + serialize===false widget fixture
'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(
// TODO(Phase B): requires live World + graphToPrompt pipeline
'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(
// TODO(Phase B): requires live World + graphToPrompt pipeline + workflow round-trip
"widgets_values_named round-trip: a workflow serialized under v2 with an on('beforeSerialize') transform deserializes to the same widget values as the equivalent v1 serializeValue workflow"
)
})
describe('async transform equivalence', () => {
it("v2 on('beforeSerialize') handler type accepts both sync and async functions", () => {
// AsyncHandler<T> = (e: T) => void | Promise<void>
// The beforeSerialize overload's handler must accept Promise return.
// We check via the on() overload signature: the second param when event='beforeSerialize'
// is typed as AsyncHandler<WidgetBeforeSerializeEvent>.
type AsyncHandlerOfEvent = (
e: WidgetBeforeSerializeEvent
) => void | Promise<void>
// Assign a sync fn — must compile:
const _sync: AsyncHandlerOfEvent = (_e) => {}
// Assign an async fn — must compile:
const _async: AsyncHandlerOfEvent = async (_e) => {}
expect(typeof _sync).toBe('function')
expect(typeof _async).toBe('function')
})
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline
"async transforms: both v1 serializeValue and v2 on('beforeSerialize') are awaited by graphToPrompt() before the workflow is finalized"
)
})
})

View File

@@ -1,74 +0,0 @@
// 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, expect } from 'vitest'
import {
createMiniComfyApp,
loadEvidenceSnippet,
countEvidenceExcerpts,
runV1
} from '@/extension-api-v2/harness'
describe('BC.12 v1 contract — per-widget serialization transform', () => {
describe('S4.W3 — widget.serializeValue assignment (structural)', () => {
it('S4.W3 has at least one evidence excerpt in the database', () => {
const count = countEvidenceExcerpts('S4.W3')
expect(count).toBeGreaterThan(0)
})
it('first S4.W3 evidence snippet contains a serializeValue assignment', () => {
const snippet = loadEvidenceSnippet('S4.W3', 0)
expect(snippet).toContain('serializeValue')
})
it('S4.W3 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S4.W3', 0)
const app = createMiniComfyApp()
// runV1 must not throw even if it cannot execute the snippet semantically.
expect(() => runV1(snippet, { app })).not.toThrow()
})
it.todo(
// TODO(Phase B): requires a synthetic LGraphNode + graphToPrompt harness
'assigning widget.serializeValue = async fn(node, index) causes graphToPrompt() to await fn and use its return value in widgets_values'
)
it.todo(
// TODO(Phase B): synthetic mock required
"serializeValue receives the owning node as first argument and the widget's positional index in node.widgets as second argument"
)
it.todo(
// TODO(Phase B): synthetic mock required
'if serializeValue is not assigned, graphToPrompt() uses widget.value directly as the serialized value'
)
it.todo(
// TODO(Phase B): synthetic mock required
'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(
// TODO(Phase B): synthetic mock required
'a widget with options.serialize===false still occupies a slot in the widgets_values positional array during serialization'
)
it.todo(
// TODO(Phase B): synthetic mock required
'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(
// TODO(Phase B): synthetic mock required
'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

@@ -1,130 +0,0 @@
// 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('beforeSerialize', handler) with event.setSerializedValue / event.skip
// Notes: WidgetHandle identity is by name not position (PR #10392 widgets_values_named migration path).
// serialize===false widgets still fire beforeSerialize and still appear in the named map.
import { describe, it, expect, expectTypeOf } from 'vitest'
import type {
WidgetHandle,
WidgetBeforeSerializeEvent,
WidgetValue
} from '@/extension-api/widget'
describe('BC.12 v2 contract — per-widget serialization transform', () => {
describe("WidgetHandle.on('beforeSerialize', handler) — event type shape", () => {
it('WidgetBeforeSerializeEvent has the correct structural shape', () => {
// Type-level check — verifies the contract surface without needing a live World.
type E = WidgetBeforeSerializeEvent
expectTypeOf<E['context']>().toEqualTypeOf<
'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
>()
expectTypeOf<E['value']>().toEqualTypeOf<WidgetValue>()
expectTypeOf<E['setSerializedValue']>().toBeFunction()
expectTypeOf<E['skip']>().toBeFunction()
})
it("WidgetHandle.on accepts 'beforeSerialize' and returns Unsubscribe", () => {
// Type-level: on('beforeSerialize') overload exists and returns () => void
type OnBeforeSerialize = WidgetHandle['on']
type Unsubscribe = ReturnType<WidgetHandle['on']>
expectTypeOf<Unsubscribe>().toEqualTypeOf<() => void>()
// The overload accepting 'beforeSerialize' must compile — verified by the
// presence of the overload signature in widget.ts.
type SerializeHandler = Parameters<
Extract<
OnBeforeSerialize,
(
event: 'beforeSerialize',
handler: (e: WidgetBeforeSerializeEvent) => void | Promise<void>
) => () => void
>
>[1]
expectTypeOf<SerializeHandler>().not.toBeNever()
})
it('beforeSerialize event context discriminant covers all four serialization paths', () => {
const contexts = [
'workflow',
'prompt',
'clone',
'subgraph-promote'
] as const
type Context = (typeof contexts)[number]
type EventContext = WidgetBeforeSerializeEvent['context']
// Exhaustiveness: every declared context literal is assignable to EventContext
const _check: Context extends EventContext ? true : never = true
expect(_check).toBe(true)
})
it('setSerializedValue accepts unknown (JSON-serializable value of any shape)', () => {
expectTypeOf<WidgetBeforeSerializeEvent['setSerializedValue']>()
.parameter(0)
.toEqualTypeOf<unknown>()
})
it('skip() takes no arguments', () => {
type SkipArity = Parameters<WidgetBeforeSerializeEvent['skip']>
expectTypeOf<SkipArity['length']>().toEqualTypeOf<0>()
})
})
describe("WidgetHandle.on('beforeSerialize', handler) — runtime behaviour", () => {
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline
"on('beforeSerialize', fn) fires fn during graphToPrompt(); calling event.setSerializedValue(v) places v in the named map under the widget name"
)
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline
'if no beforeSerialize listener is registered, graphToPrompt() uses WidgetHandle.getValue() directly'
)
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline
"calling event.skip() in a context='prompt' handler excludes the widget from the backend API prompt; the named-map entry is still written for workflow serialization"
)
it.todo(
// TODO(Phase B): requires live World + scope disposal
"on('beforeSerialize') listener is removed when the extension scope is disposed; subsequent serializations use the raw getValue() result"
)
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline
'async beforeSerialize handlers are awaited before the serialization payload is finalized'
)
})
describe('serialize===false widgets (control_after_generate)', () => {
it('isSerializeEnabled() defaults to true; setSerializeEnabled(false) disables it', () => {
// Type-level: both methods exist on WidgetHandle
expectTypeOf<WidgetHandle['isSerializeEnabled']>().toBeFunction()
expectTypeOf<WidgetHandle['setSerializeEnabled']>().toBeFunction()
type IsReturn = ReturnType<WidgetHandle['isSerializeEnabled']>
type SetParam = Parameters<WidgetHandle['setSerializeEnabled']>[0]
expectTypeOf<IsReturn>().toEqualTypeOf<boolean>()
expectTypeOf<SetParam>().toEqualTypeOf<boolean>()
})
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline
"a widget with setSerializeEnabled(false) still fires beforeSerialize with context='prompt'; the returned serializedValue is NOT sent to the backend prompt"
)
it.todo(
// TODO(Phase B): requires live World + graphToPrompt pipeline
'a widget with setSerializeEnabled(false) still appears in widgets_values_named in the workflow JSON (full round-trip preservation)'
)
it.todo(
// TODO(Phase B): requires live World
'WidgetHandle identity for a serialize===false widget is stable across slot reordering because it is name-based not position-based'
)
})
})

View File

@@ -1,404 +0,0 @@
// 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('beforeSerialize') named-map
import { describe, expect, it, vi } from 'vitest'
import type { AsyncHandler } from '@/extension-api/events'
import type { NodeBeforeSerializeEvent } from '@/extension-api/node'
// ── V1 serialization simulation ───────────────────────────────────────────────
// v1: extension patches NodeType.prototype.serialize. Each patcher wraps the
// previous and returns the modified data object.
type V1SerializeFn = (base: Record<string, unknown>) => Record<string, unknown>
function makeV1NodeType(comfyClass: string) {
let serializeFn: V1SerializeFn = (data) => data
return {
comfyClass,
patchSerialize(patcher: (orig: V1SerializeFn) => V1SerializeFn) {
const prev = serializeFn
serializeFn = patcher(prev)
},
serialize(baseData: Record<string, unknown>): Record<string, unknown> {
return serializeFn({ ...baseData })
},
// v1 onSerialize hook (alternative pattern — receives data, mutates in place)
_onSerializeHandlers: [] as Array<(data: Record<string, unknown>) => void>,
onSerialize(fn: (data: Record<string, unknown>) => void) {
this._onSerializeHandlers.push(fn)
},
serializeWithOnSerialize(
base: Record<string, unknown>
): Record<string, unknown> {
const data = this.serialize(base)
for (const fn of this._onSerializeHandlers) fn(data)
return data
}
}
}
// ── V2 serialization simulation ───────────────────────────────────────────────
type Unsubscribe = () => void
function makeV2NodeManager() {
const handlers: Array<AsyncHandler<NodeBeforeSerializeEvent>> = []
return {
on(
_event: 'beforeSerialize',
handler: AsyncHandler<NodeBeforeSerializeEvent>
): Unsubscribe {
handlers.push(handler)
return () => {
const i = handlers.indexOf(handler)
if (i !== -1) handlers.splice(i, 1)
}
},
async serialize(
baseData: Record<string, unknown>
): Promise<Record<string, unknown>> {
const data = { ...baseData }
let replacer:
| ((orig: Record<string, unknown>) => Record<string, unknown>)
| null = null
const event: NodeBeforeSerializeEvent = {
context: 'workflow',
get data() {
return data
},
replace(fn) {
replacer = fn
}
}
for (const fn of [...handlers]) {
await fn(event)
}
if (replacer !== null) {
return (
replacer as (orig: Record<string, unknown>) => Record<string, unknown>
)(data)
}
return data
}
}
}
// ── Widget value helpers ──────────────────────────────────────────────────────
interface WidgetSpec {
name: string
type: 'INT' | 'FLOAT' | 'STRING'
default: unknown
serialize?: boolean
}
function positionalSerialize(
widgets: Array<WidgetSpec & { value: unknown }>
): unknown[] {
return widgets.filter((w) => w.serialize !== false).map((w) => w.value)
}
function namedSerialize(
widgets: Array<WidgetSpec & { value: unknown }>,
warnFn: (msg: string) => void
): Record<string, unknown> {
const named: Record<string, unknown> = {}
for (const w of widgets) {
let val = w.value
if (
(w.type === 'INT' || w.type === 'FLOAT') &&
typeof val === 'number' &&
isNaN(val)
) {
warnFn(
`[ComfyUI] Widget "${w.name}" serialized NaN — substituting default (${w.default})`
)
val = w.default
}
named[w.name] = val
}
return named
}
function namedDeserialize(
named: Record<string, unknown>,
specs: WidgetSpec[],
warnFn: (msg: string) => void
): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const spec of specs) {
const raw = named[spec.name]
if ((spec.type === 'INT' || spec.type === 'FLOAT') && raw === null) {
warnFn(
`[ComfyUI] Widget "${spec.name}" loaded null for numeric — restoring default (${spec.default})`
)
out[spec.name] = spec.default
} else if (raw === undefined) {
out[spec.name] = spec.default
} else {
out[spec.name] = raw // preserve null for non-numeric widgets
}
}
return out
}
// ─────────────────────────────────────────────────────────────────────────────
describe('BC.13 migration — per-node serialization interception', () => {
describe('(a) positional v1 compat: prototype.serialize / onSerialize parity', () => {
it("custom field injected via v1 prototype.serialize patch and v2 on('beforeSerialize') both appear under identical keys", async () => {
const base = { id: 1, type: 'KSampler' }
// v1 path
const v1 = makeV1NodeType('KSampler')
v1.patchSerialize((prev) => (data) => ({
...prev(data),
custom_field: 'from-v1'
}))
const v1Result = v1.serialize(base)
expect(v1Result['custom_field']).toBe('from-v1')
// v2 path
const v2 = makeV2NodeManager()
v2.on('beforeSerialize', async (e) => {
e.data['custom_field'] = 'from-v2'
})
const v2Result = await v2.serialize(base)
expect(v2Result['custom_field']).toBe('from-v2')
// Both produce the same key — extension authors can migrate without renaming
expect(Object.keys(v1Result)).toContain('custom_field')
expect(Object.keys(v2Result)).toContain('custom_field')
})
it("v1 onSerialize and v2 on('beforeSerialize') both fire exactly once per graphToPrompt() call", async () => {
const base = { id: 2 }
// v1
const v1 = makeV1NodeType('Foo')
const v1Spy = vi.fn()
v1.onSerialize(v1Spy)
v1.serializeWithOnSerialize(base)
expect(v1Spy).toHaveBeenCalledOnce()
// v2
const v2 = makeV2NodeManager()
const v2Spy = vi.fn().mockResolvedValue(undefined)
v2.on('beforeSerialize', v2Spy)
await v2.serialize(base)
expect(v2Spy).toHaveBeenCalledOnce()
})
it('chain of two v1 prototype.serialize patchers produces same custom-field set as two v2 listeners', async () => {
const base = { id: 3 }
// v1: two chained patchers
const v1 = makeV1NodeType('Bar')
v1.patchSerialize((prev) => (data) => ({ ...prev(data), ext_a: 'A' }))
v1.patchSerialize((prev) => (data) => ({ ...prev(data), ext_b: 'B' }))
const v1Result = v1.serialize(base)
// v2: two separate listeners
const v2 = makeV2NodeManager()
v2.on('beforeSerialize', async (e) => {
e.data['ext_a'] = 'A'
})
v2.on('beforeSerialize', async (e) => {
e.data['ext_b'] = 'B'
})
const v2Result = await v2.serialize(base)
expect(v1Result['ext_a']).toBe('A')
expect(v1Result['ext_b']).toBe('B')
expect(v2Result['ext_a']).toBe('A')
expect(v2Result['ext_b']).toBe('B')
})
})
describe('(b) named-map v2 round-trip parity', () => {
it('v2 widgets_values_named deserialization produces same values as v1 positional array', () => {
const specs: WidgetSpec[] = [
{ name: 'seed', type: 'INT', default: 0 },
{ name: 'steps', type: 'INT', default: 20 },
{ name: 'cfg', type: 'FLOAT', default: 7.0 }
]
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ ...specs[0], value: 42 },
{ ...specs[1], value: 30 },
{ ...specs[2], value: 8.5 }
]
// v1: positional array
const v1Positional = positionalSerialize(widgets)
expect(v1Positional).toEqual([42, 30, 8.5])
// v2: named map → round-trip → deserialize
const named = namedSerialize(widgets, () => {})
const namedJson: Record<string, unknown> = JSON.parse(
JSON.stringify(named)
)
const v2Deserialized = namedDeserialize(namedJson, specs, () => {})
// Same values regardless of representation
specs.forEach((s) => {
const positionalIdx = specs.indexOf(s)
expect(v2Deserialized[s.name]).toBe(v1Positional[positionalIdx])
})
})
it('inserting a widget between two existing widgets does not shift named-map entries (v2), unlike v1 positional array', () => {
const specsBefore: WidgetSpec[] = [
{ name: 'seed', type: 'INT', default: 0 },
{ name: 'steps', type: 'INT', default: 20 }
]
const specsAfter: WidgetSpec[] = [
{ name: 'seed', type: 'INT', default: 0 },
{ name: 'cfg', type: 'FLOAT', default: 7.0 }, // inserted
{ name: 'steps', type: 'INT', default: 20 }
]
// v1: positional shifts — steps is at index 1 before, index 2 after insertion
const v1Before = positionalSerialize([
{ ...specsBefore[0], value: 42 },
{ ...specsBefore[1], value: 25 }
])
const v1After = positionalSerialize([
{ ...specsAfter[0], value: 42 },
{ ...specsAfter[1], value: 5.0 },
{ ...specsAfter[2], value: 25 }
])
// v1: loading old workflow after insertion reads wrong index for steps
expect(v1Before[1]).toBe(25) // steps at index 1
expect(v1After[1]).toBe(5.0) // after insertion, index 1 is cfg — CORRUPTED if loaded with old workflow
// v2: named map — steps is always steps
const namedBefore = namedSerialize(
[
{ ...specsBefore[0], value: 42 },
{ ...specsBefore[1], value: 25 }
],
() => {}
)
const namedAfter = namedSerialize(
[
{ ...specsAfter[0], value: 42 },
{ ...specsAfter[1], value: 5.0 },
{ ...specsAfter[2], value: 25 }
],
() => {}
)
// v2: steps key is stable regardless of insertion
expect(namedBefore['steps']).toBe(25)
expect(namedAfter['steps']).toBe(25)
})
it('serialize===false widget occupies named-map entry with no positional offset in v2; v1 callers must remove offset logic', () => {
const specs: WidgetSpec[] = [
{ name: 'seed', type: 'INT', default: 0 },
{
name: 'control_after_generate',
type: 'STRING',
default: 'fixed',
serialize: false
},
{ name: 'steps', type: 'INT', default: 20 }
]
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ ...specs[0], value: 1 },
{ ...specs[1], value: 'randomize', serialize: false },
{ ...specs[2], value: 10 }
]
// v1: control_after_generate is excluded from positional array
const v1Positional = positionalSerialize(widgets)
expect(v1Positional).toEqual([1, 10]) // 2 items — no slot for control_after_generate
// v2: named map includes all widgets by name; no offset computation needed
const named = namedSerialize(widgets, () => {})
expect(named['seed']).toBe(1)
expect(named['control_after_generate']).toBe('randomize')
expect(named['steps']).toBe(10)
// v1 callers that hardcoded index 1 for 'steps' must be updated — v2 uses name key
expect(v1Positional[1]).toBe(10) // v1: steps at index 1 (after filtering serialize===false)
expect(named['steps']).toBe(10) // v2: steps always at key 'steps'
})
})
describe('(c) null-in-numeric-widget: warning + default substitution', () => {
it('v1 NaN silently becomes null in JSON; v2 substitutes declared default and emits console.warn including node id and widget name', () => {
const warnMessages: string[] = []
// v1 behavior: NaN → null via JSON.stringify
const v1Value: unknown = NaN
const v1Json = JSON.parse(JSON.stringify({ val: v1Value }))
expect(v1Json.val).toBeNull() // v1: silent null
// v2 behavior: NaN → warn + substitute default
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ name: 'steps', type: 'INT', default: 20, value: NaN }
]
const named = namedSerialize(widgets, (msg) => warnMessages.push(msg))
expect(named['steps']).toBe(20) // default substituted
expect(warnMessages.length).toBe(1)
expect(warnMessages[0]).toMatch(/steps/) // widget name in message
expect(warnMessages[0]).toMatch(/NaN/)
})
it('null numeric widget loaded under v2 emits console.warn and restores declared default rather than loading null', () => {
const warnMessages: string[] = []
const specs: WidgetSpec[] = [{ name: 'cfg', type: 'FLOAT', default: 7.0 }]
// Simulate a v1-serialized workflow where cfg was NaN → null
const legacyNamed: Record<string, unknown> = { cfg: null }
const deserialized = namedDeserialize(legacyNamed, specs, (msg) =>
warnMessages.push(msg)
)
expect(deserialized['cfg']).toBe(7.0)
expect(warnMessages.length).toBe(1)
expect(warnMessages[0]).toMatch(/cfg/)
})
it('NaN guard does not trigger for non-numeric widgets whose value is legitimately null', () => {
const warnMessages: string[] = []
const specs: WidgetSpec[] = [
{ name: 'optional_lora', type: 'STRING', default: '' }
]
// STRING widget with null value — not a NaN guard scenario
const named = namedSerialize([{ ...specs[0], value: null }], (msg) =>
warnMessages.push(msg)
)
// No warning for non-numeric null
expect(warnMessages.length).toBe(0)
expect(named['optional_lora']).toBeNull()
// Also on deserialize
const deserialized = namedDeserialize(
{ optional_lora: null },
specs,
(msg) => warnMessages.push(msg)
)
expect(warnMessages.length).toBe(0)
expect(deserialized['optional_lora']).toBeNull()
})
})
})

View File

@@ -1,315 +0,0 @@
// 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, expect, it } from 'vitest'
import {
countEvidenceExcerpts,
createMiniComfyApp,
loadEvidenceSnippet,
runV1
} from '../harness'
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.13 v1 contract — per-node serialization interception', () => {
// ── S2.N6 evidence ───────────────────────────────────────────────────────────
describe('S2.N6 — evidence excerpts', () => {
it('S2.N6 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N6')).toBeGreaterThan(0)
})
it('S2.N6 evidence snippet contains serialize fingerprint', () => {
const snippet = loadEvidenceSnippet('S2.N6', 0)
expect(snippet).toMatch(/serialize/i)
})
it('S2.N6 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N6', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
// ── S2.N15 evidence ──────────────────────────────────────────────────────────
describe('S2.N15 — evidence excerpts', () => {
it('S2.N15 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N15')).toBeGreaterThan(0)
})
it('S2.N15 evidence snippet contains onSerialize fingerprint', () => {
const count = countEvidenceExcerpts('S2.N15')
let found = false
for (let i = 0; i < count; i++) {
const snippet = loadEvidenceSnippet('S2.N15', i)
if (/onSerialize|serialize/i.test(snippet)) {
found = true
break
}
}
expect(
found,
'Expected at least one S2.N15 excerpt with onSerialize fingerprint'
).toBe(true)
})
it('S2.N15 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S2.N15', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
// ── S2.N6 synthetic behavior ─────────────────────────────────────────────────
describe('S2.N6 — prototype.serialize patching', () => {
it('patching prototype.serialize and chaining origSerialize includes base fields plus custom fields', () => {
interface MockNode {
id: number
type: string
widgets_values: unknown[]
serialize(): Record<string, unknown>
}
const baseSerialize = function (this: MockNode) {
return {
id: this.id,
type: this.type,
widgets_values: this.widgets_values
}
}
const NodeProto: {
serialize: (this: MockNode) => Record<string, unknown>
} = {
serialize: baseSerialize
}
// Extension patches
const origSerialize = NodeProto.serialize
NodeProto.serialize = function (this: MockNode) {
const r = origSerialize.call(this)
r.myData = 'hello'
return r
}
const node = Object.assign(Object.create(NodeProto) as MockNode, {
id: 1,
type: 'KSampler',
widgets_values: [42]
})
const result = node.serialize()
expect(result.myData).toBe('hello')
expect(result.id).toBe(1)
expect(result.type).toBe('KSampler')
expect(result.widgets_values).toEqual([42])
})
it('multiple extensions chaining each contribute their custom fields', () => {
interface MockNode {
id: number
type: string
widgets_values: unknown[]
serialize(): Record<string, unknown>
}
const baseSerialize = function (this: MockNode) {
return {
id: this.id,
type: this.type,
widgets_values: this.widgets_values
}
}
const NodeProto: {
serialize: (this: MockNode) => Record<string, unknown>
} = {
serialize: baseSerialize
}
// Extension A patches first
const orig1 = NodeProto.serialize
NodeProto.serialize = function (this: MockNode) {
const r = orig1.call(this)
r.extensionA = 'data-from-A'
return r
}
// Extension B patches second
const orig2 = NodeProto.serialize
NodeProto.serialize = function (this: MockNode) {
const r = orig2.call(this)
r.extensionB = 'data-from-B'
return r
}
const node = Object.assign(Object.create(NodeProto) as MockNode, {
id: 2,
type: 'VAEDecode',
widgets_values: []
})
const result = node.serialize()
expect(result.extensionA).toBe('data-from-A')
expect(result.extensionB).toBe('data-from-B')
expect(result.id).toBe(2)
})
it('positional widgets_values in the patched serialize output drifts when a serialize===false widget occupies a slot before the target widget', () => {
// Demonstrates how serialize===false widgets cause positional drift between
// frontend serialization (all widgets) and backend prompt (only serializable widgets)
interface MockWidget {
name: string
value: unknown
options?: { serialize?: boolean }
}
interface MockNode {
id: number
type: string
widgets: MockWidget[]
serialize(): { id: number; type: string; widgets_values: unknown[] }
}
// Create a node with 3 widgets, middle one has serialize===false
const node: MockNode = {
id: 1,
type: 'KSampler',
widgets: [
{ name: 'steps', value: 20 },
{
name: 'control_after_generate',
value: 'fixed',
options: { serialize: false }
},
{ name: 'cfg', value: 7.5 }
],
serialize() {
// v1 serialize includes ALL widgets positionally (including serialize===false)
return {
id: this.id,
type: this.type,
widgets_values: this.widgets.map((w) => w.value)
}
}
}
const serialized = node.serialize()
// Frontend serialize output: all 3 widgets present
expect(serialized.widgets_values).toEqual([20, 'fixed', 7.5])
expect(serialized.widgets_values).toHaveLength(3)
// Simulate what graphToPrompt sends to backend (excludes serialize===false)
const backendWidgetsValues = node.widgets
.filter((w) => w.options?.serialize !== false)
.map((w) => w.value)
// Backend sees only 2 widgets - positional drift!
expect(backendWidgetsValues).toEqual([20, 7.5])
expect(backendWidgetsValues).toHaveLength(2)
// Drift: cfg is at index 2 in frontend, but index 1 in backend
expect(serialized.widgets_values[2]).toBe(7.5) // frontend: cfg at index 2
expect(backendWidgetsValues[1]).toBe(7.5) // backend: cfg at index 1
})
})
// ── S2.N15 synthetic behavior ────────────────────────────────────────────────
describe('S2.N15 — node.onSerialize callback', () => {
it('onSerialize mutates data in place; mutation is reflected in result', () => {
const data = { id: 1, widgets_values: [42] } as Record<string, unknown>
const node = {
onSerialize: (d: Record<string, unknown>) => {
d.extra = 'injected'
}
}
// Simulate LiteGraph calling onSerialize after base serialize
node.onSerialize(data)
expect(data.extra).toBe('injected')
})
it('onSerialize fires twice when serialized twice', () => {
const calls: number[] = []
const data1 = { id: 1, widgets_values: [] } as Record<string, unknown>
const data2 = { id: 1, widgets_values: [] } as Record<string, unknown>
const node = {
onSerialize: (d: Record<string, unknown>) => {
calls.push(calls.length)
d.callIndex = calls.length
}
}
node.onSerialize(data1)
node.onSerialize(data2)
expect(calls).toHaveLength(2)
expect(data1.callIndex).toBe(1)
expect(data2.callIndex).toBe(2)
})
it.todo(
'real graphToPrompt integration: onSerialize fires once per graphToPrompt call in the real app'
)
it('positional drift with serialize===false widgets: NaN values written inside onSerialize are silently coerced to null by JSON.stringify', () => {
// Demonstrates that NaN values injected via onSerialize become null after JSON round-trip
// This is especially problematic with positional drift from serialize===false widgets
interface MockWidget {
name: string
value: unknown
options?: { serialize?: boolean }
}
const node = {
widgets: [
{ name: 'steps', value: 20 },
{
name: 'control_after_generate',
value: 'fixed',
options: { serialize: false }
},
{ name: 'denoise', value: 1.0 }
] as MockWidget[],
onSerialize: (data: { widgets_values: unknown[] }) => {
// Extension injects NaN via onSerialize (e.g., invalid computation result)
data.widgets_values[2] = NaN
}
}
// Simulate serialize + onSerialize flow
const data = {
id: 1,
widgets_values: node.widgets.map((w) => w.value)
}
node.onSerialize(data)
// Before JSON round-trip: NaN is present
expect(Number.isNaN(data.widgets_values[2])).toBe(true)
// JSON round-trip silently corrupts NaN to null
const restored = JSON.parse(JSON.stringify(data)) as typeof data
expect(restored.widgets_values[2]).toBeNull()
// Combined with positional drift: if workflow is restored on a version
// without the serialize===false widget, the null lands on wrong widget
// Original: [steps=20, control='fixed', denoise=NaN→null]
// Without control_after_generate: indices shift, null could corrupt 'steps'
})
})
// ── NaN→null silent corruption ───────────────────────────────────────────────
describe('NaN→null silent corruption', () => {
it('JSON.stringify(NaN) === "null", and JSON.parse("null") === null — synthetic proof', () => {
const widgets_values = [NaN]
const serialized = JSON.stringify(widgets_values) // "[null]"
const restored = JSON.parse(serialized) as unknown[]
expect(restored[0]).toBeNull()
})
it('restored null is not equal to 0 and not equal to widget default', () => {
const widgets_values = [NaN]
const serialized = JSON.stringify(widgets_values)
const restored = JSON.parse(serialized) as unknown[]
const restoredValue = restored[0]
const widgetDefault = 0
expect(restoredValue).not.toBe(0)
expect(restoredValue).not.toBe(widgetDefault)
expect(restoredValue).toBeNull()
})
})
})

View File

@@ -1,370 +0,0 @@
// 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('beforeSerialize', async (e) => { e.data.myData = ... })
// 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, expect, it, vi } from 'vitest'
import type { AsyncHandler } from '@/extension-api/events'
import type { NodeBeforeSerializeEvent } from '@/extension-api/node'
// ── Minimal NodeBeforeSerializeEvent factory ──────────────────────────────────
interface WidgetSpec {
name: string
type: 'INT' | 'FLOAT' | 'STRING' | 'BOOLEAN'
default: unknown
serialize?: boolean
}
interface SerializedNode {
id: number
type: string
widgets_values_named: Record<string, unknown>
[key: string]: unknown
}
function makeEvent(
overrides: Partial<NodeBeforeSerializeEvent> & {
initialData?: Record<string, unknown>
} = {}
): NodeBeforeSerializeEvent & { _getData(): Record<string, unknown> } {
const data: Record<string, unknown> = { ...(overrides.initialData ?? {}) }
let replacer:
| ((orig: Record<string, unknown>) => Record<string, unknown>)
| null = null
const event: NodeBeforeSerializeEvent & {
_getData(): Record<string, unknown>
} = {
context: overrides.context ?? 'workflow',
get data() {
return data
},
replace(fn) {
replacer = fn
},
_getData() {
return replacer ? replacer(data) : data
}
}
return event
}
// ── Minimal NodeHandle-like subscription manager ──────────────────────────────
type Unsubscribe = () => void
function makeNodeSubscriptionManager() {
const listeners: Array<AsyncHandler<NodeBeforeSerializeEvent>> = []
return {
on(
_event: 'beforeSerialize',
handler: AsyncHandler<NodeBeforeSerializeEvent>
): Unsubscribe {
listeners.push(handler)
return () => {
const idx = listeners.indexOf(handler)
if (idx !== -1) listeners.splice(idx, 1)
}
},
async dispatch(event: NodeBeforeSerializeEvent): Promise<void> {
for (const fn of [...listeners]) {
await fn(event)
}
},
listenerCount() {
return listeners.length
}
}
}
// ── Named-map serializer simulator ───────────────────────────────────────────
function serializeWidgets(widgets: Array<WidgetSpec & { value: unknown }>): {
named: Record<string, unknown>
warnings: string[]
} {
const named: Record<string, unknown> = {}
const warnings: string[] = []
for (const w of widgets) {
if (w.serialize === false) {
named[w.name] = w.value // still in named map, just not in positional
continue
}
let val = w.value
if (
(w.type === 'INT' || w.type === 'FLOAT') &&
typeof val === 'number' &&
isNaN(val)
) {
warnings.push(
`[ComfyUI] Widget "${w.name}" on node serialized NaN — substituting default (${w.default})`
)
val = w.default
}
named[w.name] = val
}
return { named, warnings }
}
// ─────────────────────────────────────────────────────────────────────────────
describe('BC.13 v2 contract — per-node serialization interception', () => {
describe("NodeHandle.on('beforeSerialize', fn) — node-level serialization hook (S2.N6, S2.N15)", () => {
it('fires fn with the serialization data object during graphToPrompt(); fn may add custom fields', async () => {
const node = makeNodeSubscriptionManager()
const event = makeEvent({ initialData: { id: 1, type: 'KSampler' } })
node.on('beforeSerialize', async (e) => {
e.data['my_field'] = 'injected'
})
await node.dispatch(event)
expect(event._getData()['my_field']).toBe('injected')
})
it("custom fields added inside on('beforeSerialize') are present in the workflow JSON under the node's entry", async () => {
const node = makeNodeSubscriptionManager()
const initialData: Record<string, unknown> = {
id: 42,
type: 'PreviewImage'
}
const event = makeEvent({ initialData })
node.on('beforeSerialize', async (e) => {
e.data['preview_count'] = 5
e.data['last_preview_url'] = 'blob://abc'
})
await node.dispatch(event)
const serialized: SerializedNode = {
...(event._getData() as object),
widgets_values_named: {}
} as SerializedNode
const json = JSON.parse(JSON.stringify(serialized))
expect(json['preview_count']).toBe(5)
expect(json['last_preview_url']).toBe('blob://abc')
})
it('multiple listeners from different extensions all fire and their custom fields coexist', async () => {
const node = makeNodeSubscriptionManager()
const event = makeEvent({ initialData: { id: 7 } })
node.on('beforeSerialize', async (e) => {
e.data['ext_a'] = 'from-A'
})
node.on('beforeSerialize', async (e) => {
e.data['ext_b'] = 'from-B'
})
node.on('beforeSerialize', async (e) => {
e.data['ext_c'] = 'from-C'
})
await node.dispatch(event)
expect(event._getData()['ext_a']).toBe('from-A')
expect(event._getData()['ext_b']).toBe('from-B')
expect(event._getData()['ext_c']).toBe('from-C')
})
it('listener removed via unsubscribe; subsequent serializations omit its custom fields', async () => {
const node = makeNodeSubscriptionManager()
const unsub = node.on('beforeSerialize', async (e) => {
e.data['removed_field'] = 'should-not-appear'
})
unsub()
expect(node.listenerCount()).toBe(0)
const event = makeEvent({ initialData: {} })
await node.dispatch(event)
expect(event._getData()['removed_field']).toBeUndefined()
})
it('async handler is fully awaited before the next listener runs', async () => {
const node = makeNodeSubscriptionManager()
const order: number[] = []
node.on('beforeSerialize', async (e) => {
await new Promise<void>((r) => setTimeout(r, 10))
order.push(1)
e.data['step'] = 1
})
node.on('beforeSerialize', async (e) => {
// Must see step=1 from the prior handler
order.push(2)
e.data['saw_step'] = e.data['step']
})
const event = makeEvent({ initialData: {} })
await node.dispatch(event)
expect(order).toEqual([1, 2])
expect(event._getData()['saw_step']).toBe(1)
})
it('replace() replaces the entire data object; later listeners see the new object', async () => {
const node = makeNodeSubscriptionManager()
const event = makeEvent({ initialData: { id: 3, orig: true } })
node.on('beforeSerialize', async (e) => {
e.replace((orig) => ({ ...orig, wrapped: true, orig: false }))
})
await node.dispatch(event)
const final = event._getData()
expect(final['wrapped']).toBe(true)
expect(final['orig']).toBe(false)
})
it("context field is passed correctly for 'prompt' serialization context", async () => {
const node = makeNodeSubscriptionManager()
let capturedContext: string | undefined
node.on('beforeSerialize', async (e) => {
capturedContext = e.context
})
const event = makeEvent({ context: 'prompt', initialData: {} })
await node.dispatch(event)
expect(capturedContext).toBe('prompt')
})
})
describe('named-map round-trip (widgets_values_named)', () => {
it('stores widget values keyed by name; map survives JSON round-trip with no null drift', () => {
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ name: 'seed', type: 'INT', default: 0, value: 42 },
{ name: 'steps', type: 'INT', default: 20, value: 30 },
{ name: 'cfg', type: 'FLOAT', default: 7.0, value: 8.5 },
{
name: 'sampler_name',
type: 'STRING',
default: 'euler',
value: 'dpm_2'
}
]
const { named } = serializeWidgets(widgets)
const roundTripped: Record<string, unknown> = JSON.parse(
JSON.stringify({ named })
).named
expect(roundTripped['seed']).toBe(42)
expect(roundTripped['steps']).toBe(30)
expect(roundTripped['cfg']).toBe(8.5)
expect(roundTripped['sampler_name']).toBe('dpm_2')
})
it('workflow with three widgets including serialize===false deserializes correctly regardless of insertion order', () => {
const specs: WidgetSpec[] = [
{ name: 'seed', type: 'INT', default: 0 },
{
name: 'control_after_generate',
type: 'STRING',
default: 'fixed',
serialize: false
},
{ name: 'steps', type: 'INT', default: 20 }
]
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ ...specs[0], value: 99 },
{ ...specs[1], value: 'randomize', serialize: false },
{ ...specs[2], value: 15 }
]
const { named } = serializeWidgets(widgets)
// Named map contains all three regardless of insertion order
expect(named['seed']).toBe(99)
expect(named['steps']).toBe(15)
// serialize===false widget still has a named entry (no positional corruption)
expect('control_after_generate' in named).toBe(true)
})
it('widgets added or removed between passes do not corrupt unaffected entries', () => {
const pass1: Array<WidgetSpec & { value: unknown }> = [
{ name: 'seed', type: 'INT', default: 0, value: 1 },
{ name: 'steps', type: 'INT', default: 20, value: 25 }
]
const { named: named1 } = serializeWidgets(pass1)
// Simulate adding a widget between seed and steps
const pass2: Array<WidgetSpec & { value: unknown }> = [
{ name: 'seed', type: 'INT', default: 0, value: 1 },
{ name: 'cfg', type: 'FLOAT', default: 7.0, value: 5.0 }, // new
{ name: 'steps', type: 'INT', default: 20, value: 25 }
]
const { named: named2 } = serializeWidgets(pass2)
// 'steps' is still keyed by name — no positional shift
expect(named1['steps']).toBe(25)
expect(named2['steps']).toBe(25)
expect(named2['cfg']).toBe(5.0)
})
})
describe('NaN→null guard (numeric widget safety)', () => {
it('NaN numeric widget: v2 logs console.warn and substitutes declared default', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ name: 'steps', type: 'INT', default: 20, value: NaN }
]
const { named, warnings } = serializeWidgets(widgets)
expect(named['steps']).toBe(20)
expect(warnings.length).toBe(1)
expect(warnings[0]).toMatch(/steps/)
expect(warnings[0]).toMatch(/NaN/)
warnSpy.mockRestore()
})
it('substituted default value round-trips through JSON correctly', () => {
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ name: 'cfg', type: 'FLOAT', default: 7.5, value: NaN }
]
const { named } = serializeWidgets(widgets)
const json = JSON.parse(JSON.stringify({ named })).named
expect(json['cfg']).toBe(7.5)
expect(json['cfg']).not.toBeNull()
})
it('NaN guard per-widget; does not abort remaining widgets on the same node', () => {
const widgets: Array<WidgetSpec & { value: unknown }> = [
{ name: 'seed', type: 'INT', default: 0, value: NaN },
{ name: 'steps', type: 'INT', default: 20, value: 30 },
{ name: 'cfg', type: 'FLOAT', default: 7.0, value: NaN }
]
const { named, warnings } = serializeWidgets(widgets)
// Two NaN widgets both substituted; steps unaffected
expect(warnings.length).toBe(2)
expect(named['seed']).toBe(0)
expect(named['steps']).toBe(30)
expect(named['cfg']).toBe(7.0)
})
})
})

View File

@@ -1,267 +0,0 @@
// 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: MUST pass before v2 ships
// Migration: v1 app.graphToPrompt monkey-patch (S6.A1) → v2 ctx.on('beforePrompt', handler)
//
// S6.A1 classification: 'uwf-resolved' — full migration path goes through UWF Phase 3
// save-time materialization, not beforePrompt alone (decisions/D9 §Phase B, I-PG.B2).
//
// Phase A: No runtime for ctx.on('beforePrompt') yet. This file proves:
// (a) Structural equivalence of v1 monkey-patch and v2 event handler patterns in TypeScript
// (b) That ExtensionOptions.setup() is the Phase B hook point for beforePrompt registration
// (c) That v1 patch call-log patterns are reproducible in a typed event model
// All runtime equivalence cases are marked todo(Phase B + UWF Phase 3).
import { describe, expect, it, vi } from 'vitest'
import type { ExtensionOptions } from '@/extension-api/lifecycle'
// ── V1 pattern: graphToPrompt monkey-patch ────────────────────────────────────
// Models the S6.A1 pattern: extensions replace app.graphToPrompt with a wrapper
// that intercepts the payload, mutates it, then calls the original.
interface ApiPromptOutput {
[nodeId: string]: { class_type: string; inputs: Record<string, unknown> }
}
interface WorkflowJson {
nodes: unknown[]
links: unknown[]
}
interface V1App {
graphToPrompt(): { output: ApiPromptOutput; workflow: WorkflowJson }
}
function createV1App(
baseOutput: ApiPromptOutput = {}
): V1App & { callLog: string[] } {
const callLog: string[] = []
return {
callLog,
graphToPrompt() {
callLog.push('original')
return {
output: { ...baseOutput },
workflow: { nodes: [], links: [] }
}
}
}
}
function applyV1Patch(
app: V1App & { callLog: string[] },
patcher: (payload: {
output: ApiPromptOutput
workflow: WorkflowJson
}) => void
) {
const original = app.graphToPrompt.bind(app)
app.graphToPrompt = function () {
const result = original()
patcher(result)
app.callLog.push('patched')
return result
}
}
// ── V2 pattern: typed event handler ──────────────────────────────────────────
// Models what ctx.on('beforePrompt', handler) will look like in Phase B.
// The event object is a plain record matching the anticipated BeforePromptEvent shape.
interface BeforePromptEvent {
spec: ApiPromptOutput
workflow: WorkflowJson
reject(reason: string): void
}
function createV2EventBus() {
const handlers: Array<(e: BeforePromptEvent) => void> = []
const rejections: string[] = []
function on(_event: 'beforePrompt', handler: (e: BeforePromptEvent) => void) {
handlers.push(handler)
}
function emit(
spec: ApiPromptOutput,
workflow: WorkflowJson
): { spec: ApiPromptOutput; rejected: string | null } {
const event: BeforePromptEvent = {
spec: { ...spec },
workflow,
reject(reason) {
rejections.push(reason)
}
}
for (const h of handlers) h(event)
return {
spec: event.spec,
rejected: rejections.length > 0 ? rejections[0] : null
}
}
return { on, emit }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.14 migration — graphToPrompt interception', () => {
describe('structural equivalence of v1 patch and v2 event handler (type-level)', () => {
it('v1 monkey-patch intercepts graphToPrompt and can mutate output keys', () => {
const app = createV1App({
'1': { class_type: 'KSampler', inputs: { steps: 20 } }
})
applyV1Patch(app, (payload) => {
payload.output['99'] = { class_type: 'VirtualNode', inputs: {} }
})
const result = app.graphToPrompt()
expect(result.output).toHaveProperty('99')
expect(app.callLog).toEqual(['original', 'patched'])
})
it('v2 beforePrompt handler receives a spec object and can mutate it', () => {
const bus = createV2EventBus()
bus.on('beforePrompt', (e) => {
e.spec['99'] = { class_type: 'VirtualNode', inputs: {} }
})
const baseSpec: ApiPromptOutput = {
'1': { class_type: 'KSampler', inputs: { steps: 20 } }
}
const { spec } = bus.emit(baseSpec, { nodes: [], links: [] })
expect(spec).toHaveProperty('99')
})
it('both v1 and v2 can inject a custom metadata key into the prompt output', () => {
// v1
const appV1 = createV1App({ '1': { class_type: 'KSampler', inputs: {} } })
applyV1Patch(appV1, (payload) => {
payload.output['_meta'] = {
class_type: '__metadata__',
inputs: { version: '1.0' }
}
})
const v1Result = appV1.graphToPrompt()
// v2
const bus = createV2EventBus()
bus.on('beforePrompt', (e) => {
e.spec['_meta'] = {
class_type: '__metadata__',
inputs: { version: '1.0' }
}
})
const { spec: v2Spec } = bus.emit(
{ '1': { class_type: 'KSampler', inputs: {} } },
{ nodes: [], links: [] }
)
expect(v1Result.output['_meta']).toEqual(v2Spec['_meta'])
})
it('v1 patch call order: original fires before patch callback — matches v2 handler-before-dispatch ordering', () => {
const app = createV1App()
const order: string[] = []
const originalFn = app.graphToPrompt.bind(app)
app.graphToPrompt = function () {
const r = originalFn()
order.push('patch-handler')
return r
}
app.graphToPrompt()
expect(order[0]).toBe('patch-handler')
expect(app.callLog[0]).toBe('original')
})
})
describe('ExtensionOptions.setup() as the Phase B hook registration point', () => {
it('ExtensionOptions.setup() is defined and can hold async logic (Phase B: register ctx.on here)', () => {
// Phase B: inside setup(), ctx = getCurrentExtensionContext(); ctx.on('beforePrompt', fn)
// Phase A: prove setup() accepts async functions and ExtensionOptions compiles correctly.
const registered: string[] = []
const ext: ExtensionOptions = {
name: 'bc14.mig.setup',
apiVersion: '2',
async setup() {
// Phase B: ctx.on('beforePrompt', handler) goes here
registered.push('setup-called')
}
}
expect(typeof ext.setup).toBe('function')
const result = ext.setup!()
expect(result).toBeInstanceOf(Promise)
return Promise.resolve(result).then(() => {
expect(registered).toContain('setup-called')
})
})
it('[gap] ExtensionOptions has no beforePrompt field — ctx.on() is the registration mechanism (Phase B)', () => {
// Confirms the pattern: extensions do NOT declare beforePrompt on the options object.
// The handler is registered imperatively inside setup() via the context API.
// This is intentional per D6 §Q4 (no declarative field to avoid Phase A surface bloat).
const ext: ExtensionOptions = { name: 'bc14.mig.gap', setup() {} }
expect('beforePrompt' in ext).toBe(false)
})
})
describe('v2 cancellation shape (type-level)', () => {
it('v2 BeforePromptEvent.reject(reason) is callable and prevents further processing', () => {
const bus = createV2EventBus()
const afterReject = vi.fn()
bus.on('beforePrompt', (e) => {
e.reject('missing required node')
})
bus.on('beforePrompt', afterReject) // second handler still fires in Phase A model
const { rejected } = bus.emit({}, { nodes: [], links: [] })
expect(rejected).toBe('missing required node')
})
})
describe('multiple v2 handlers — each sees prior mutations', () => {
it('handler B sees metadata injected by handler A in the same event cycle', () => {
const bus = createV2EventBus()
bus.on('beforePrompt', (e) => {
e.spec['from-A'] = { class_type: 'A', inputs: {} }
})
bus.on('beforePrompt', (e) => {
e.spec['from-B'] = {
class_type: 'B',
inputs: { sawA: 'from-A' in e.spec }
}
})
const { spec } = bus.emit({}, { nodes: [], links: [] })
expect(spec['from-A']).toBeDefined()
expect(spec['from-B'].inputs['sawA']).toBe(true)
})
})
})
// ── Phase B + UWF Phase 3 stubs ───────────────────────────────────────────────
describe('BC.14 migration — graphToPrompt runtime parity [Phase B + UWF Phase 3]', () => {
it.todo(
'[Phase B/C] v1 monkey-patch and v2 ctx.on("beforePrompt") handler produce identical ApiPromptOutput when given the same base graph'
)
it.todo(
'[Phase B/C] removing the v1 monkey-patch while keeping the v2 handler produces identical final prompt payload'
)
it.todo(
'[Phase B/C] v1 patch active alongside v2 handler does not double-mutate the payload (coexistence window)'
)
it.todo(
'[Phase B/C] v1 throwing inside the patch (cancellation) has equivalent effect to v2 event.reject(reason)'
)
it.todo(
'[UWF Phase 3] S6.A1 graphToPrompt patches that filter virtual nodes are fully replaced by UWF Phase 3 save-time materialization — no extension code needed'
)
it.todo(
'[UWF Phase 3] S9.SG1 Set/Get virtual node connection resolution produces identical backend prompt via resolveConnections vs v1 graphToPrompt patch'
)
})

View File

@@ -1,288 +0,0 @@
// 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, expect, it } from 'vitest'
import {
countEvidenceExcerpts,
createMiniComfyApp,
loadEvidenceSnippet,
runV1
} from '../harness'
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.14 v1 contract — graphToPrompt monkey-patch', () => {
// ── S6.A1 evidence ───────────────────────────────────────────────────────────
describe('S6.A1 — evidence excerpts', () => {
it('S6.A1 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S6.A1')).toBeGreaterThan(0)
})
it('S6.A1 evidence snippet contains graphToPrompt fingerprint', () => {
const snippet = loadEvidenceSnippet('S6.A1', 0)
expect(snippet).toMatch(/graphToPrompt/i)
})
it('S6.A1 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S6.A1', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
// ── S6.A1 synthetic behavior ─────────────────────────────────────────────────
describe('S6.A1 — app.graphToPrompt interception', () => {
it('extension wraps graphToPrompt and calls original; result passes through', async () => {
const mockPrompt = {
output: { '1': { class_type: 'KSampler', inputs: {} } },
workflow: {}
}
const app = {
graphToPrompt: async () => ({ ...mockPrompt })
}
// Extension wraps
const orig = app.graphToPrompt.bind(app)
app.graphToPrompt = async function (...args: Parameters<typeof orig>) {
const r = await orig(...args)
return r
}
const result = await app.graphToPrompt()
expect(result.output).toEqual(mockPrompt.output)
})
it('mutations to the resolved prompt object are reflected in the final result', async () => {
const mockPrompt = {
output: { '1': { class_type: 'KSampler', inputs: {} } } as Record<
string,
unknown
>,
workflow: {} as Record<string, unknown>
}
const app = {
graphToPrompt: async () => ({
...mockPrompt,
output: { ...mockPrompt.output }
})
}
// Extension adds custom metadata
const orig = app.graphToPrompt.bind(app)
app.graphToPrompt = async function () {
const r = await orig()
r.output['meta'] = {
custom: true
} as unknown as (typeof r.output)[string]
return r
}
const result = await app.graphToPrompt()
expect((result.output['meta'] as Record<string, unknown>).custom).toBe(
true
)
})
it('multiple wrappers in sequence each see prior mutations', async () => {
const base = {
output: { '1': { class_type: 'KSampler', inputs: {} } } as Record<
string,
unknown
>,
workflow: {} as Record<string, unknown>
}
const app = {
graphToPrompt: async () => ({ ...base, output: { ...base.output } })
}
// Extension A wraps first
const origA = app.graphToPrompt.bind(app)
app.graphToPrompt = async function () {
const r = await origA()
r.output['fromA'] = true as unknown as (typeof r.output)[string]
return r
}
// Extension B wraps second (outermost)
const origB = app.graphToPrompt.bind(app)
app.graphToPrompt = async function () {
const r = await origB()
r.output['fromB'] = true as unknown as (typeof r.output)[string]
return r
}
const result = await app.graphToPrompt()
// Both extensions should have contributed
expect(result.output['fromA']).toBe(true)
expect(result.output['fromB']).toBe(true)
})
it('wrapper receives same args passed by caller (args pass-through)', async () => {
const receivedArgs: unknown[][] = []
const app = {
graphToPrompt: async (...args: unknown[]) => {
receivedArgs.push(args)
return { output: {}, workflow: {} }
}
}
const orig = app.graphToPrompt.bind(app)
app.graphToPrompt = async function (...args: Parameters<typeof orig>) {
return orig(...args)
}
// Call with no args — the wrapper must pass them through unchanged
await app.graphToPrompt()
expect(receivedArgs).toHaveLength(1)
})
it('virtual node resolution: virtual nodes resolved by the extension wrapper are absent from the serialized output sent to the backend', async () => {
// Mirror the real graphToPrompt contract: a virtual node (e.g. a
// group node, primitive node, or reroute) contributes its inner
// nodes to `output` but the virtual node itself must NOT appear
// in the serialized API workflow. The wrapper performs that
// resolution step before returning.
const app = {
graphToPrompt: async () => ({
output: {
// Virtual group node — should be stripped by the wrapper.
'1': {
class_type: 'GroupNode',
isVirtualNode: true,
inputs: {}
},
// Inner node contributed by the virtual node — kept.
'2': { class_type: 'KSampler', inputs: {} },
// Independent real node — kept.
'3': { class_type: 'VAEDecode', inputs: {} }
} as Record<
string,
{
class_type: string
isVirtualNode?: boolean
inputs: Record<string, unknown>
}
>,
workflow: {} as Record<string, unknown>
})
}
// Extension wraps and resolves virtual nodes out of the payload.
const orig = app.graphToPrompt.bind(app)
app.graphToPrompt = async function () {
const r = await orig()
for (const id of Object.keys(r.output)) {
if (r.output[id].isVirtualNode) {
delete r.output[id]
}
}
return r
}
const result = await app.graphToPrompt()
expect(Object.keys(result.output).sort()).toEqual(['2', '3'])
expect(result.output['1']).toBeUndefined()
// Inner + independent real nodes survive the resolution pass.
expect(result.output['2'].class_type).toBe('KSampler')
expect(result.output['3'].class_type).toBe('VAEDecode')
})
it('full queuePrompt: custom metadata injected into prompt.output is preserved through the full queuePrompt call', async () => {
// The v1 pattern wraps graphToPrompt, but the *contract* the
// extension cares about is "what the backend receives via
// queuePrompt(p)". This test asserts the metadata survives the
// full pipe: wrapped-graphToPrompt → queuePrompt → backend.
const seenByBackend: Array<Record<string, unknown>> = []
const app = {
graphToPrompt: async () => ({
output: { '1': { class_type: 'KSampler', inputs: {} } } as Record<
string,
unknown
>,
workflow: {} as Record<string, unknown>
}),
async queuePrompt(_n: number) {
const p = await app.graphToPrompt()
seenByBackend.push(p.output)
return { prompt_id: 'abc' }
}
}
// Extension wraps graphToPrompt and adds custom metadata.
const orig = app.graphToPrompt.bind(app)
app.graphToPrompt = async function () {
const r = await orig()
r.output['extra_pnginfo'] = {
workflow_hash: 'deadbeef',
custom: true
} as unknown as (typeof r.output)[string]
return r
}
// Caller invokes queuePrompt — the backend should observe the
// injected metadata.
const res = await app.queuePrompt(0)
expect(res.prompt_id).toBe('abc')
expect(seenByBackend).toHaveLength(1)
const sent = seenByBackend[0]
expect(sent['extra_pnginfo']).toEqual({
workflow_hash: 'deadbeef',
custom: true
})
// Original node still present.
expect((sent['1'] as { class_type: string }).class_type).toBe('KSampler')
})
it('real graphToPrompt implementation: multiple extensions wrapping graphToPrompt via real app wiring all fire in correct order', async () => {
// Two extensions register against the same app object, each
// monkey-patching graphToPrompt in turn. The execution order is
// outermost-first (B wraps after A, so B runs first and then
// delegates to A). Capture firing order via a log.
const order: string[] = []
const base = {
output: { '1': { class_type: 'KSampler', inputs: {} } } as Record<
string,
unknown
>,
workflow: {} as Record<string, unknown>
}
const app = {
async graphToPrompt() {
order.push('original')
return { ...base, output: { ...base.output } }
}
}
// Simulate registerExtension wiring — each extension grabs the
// current app.graphToPrompt and replaces it. Order of
// registration matters: first-registered runs nearest to the
// original; last-registered runs outermost.
function registerWrapper(label: string) {
const orig = app.graphToPrompt.bind(app)
app.graphToPrompt = async function () {
order.push(`${label}:before`)
const r = await orig()
order.push(`${label}:after`)
;(r.output as Record<string, unknown>)[label] = true
return r
}
}
registerWrapper('A') // registers first — innermost
registerWrapper('B') // registers second — middle
registerWrapper('C') // registers third — outermost
const result = await app.graphToPrompt()
// All three contributed.
expect(result.output['A']).toBe(true)
expect(result.output['B']).toBe(true)
expect(result.output['C']).toBe(true)
// Firing order: outermost (C) enters first, then B, then A,
// then original, then unwind in reverse.
expect(order).toEqual([
'C:before',
'B:before',
'A:before',
'original',
'A:after',
'B:after',
'C:after'
])
})
})
})

View File

@@ -1,126 +0,0 @@
// 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: MUST pass before v2 ships
//
// v2 replacement (Phase B): ctx.on('beforePrompt', handler) inside defineExtension setup context.
// Full spec: decisions/D6-parallel-paths-migration.md §Q4
// Virtual nodes (Phase B): virtual:true + resolveConnections(node, graph) → edges[]
// Full spec: decisions/D6-parallel-paths-migration.md §Q5
// S6.A1 classification: 'uwf-resolved' — full migration requires UWF Phase 3 save-time
// materialization (not beforePrompt alone). See decisions/D9-strangler-fig-phases.md §Phase B.
//
// Phase A: beforePrompt is NOT yet on ExtensionOptions; virtual/resolveConnections are NOT yet
// on NodeExtensionOptions. These are Phase B additions pending D6 §Q4/Q5 sign-off.
// This file tests the current type surface and documents gaps precisely.
import { describe, expect, it } from 'vitest'
import type {
ExtensionOptions,
NodeExtensionOptions
} from '@/extension-api/lifecycle'
// ── Phase A — type surface tests ─────────────────────────────────────────────
describe('BC.14 v2 contract — graphToPrompt interception (Phase A type surface)', () => {
describe('ExtensionOptions — current stable surface', () => {
it('ExtensionOptions accepts name, apiVersion, init, and setup — the full Phase A surface', () => {
// Confirm the stable fields compile and accept correct types.
const ext: ExtensionOptions = {
name: 'bc14.test.ext',
apiVersion: '2',
init() {},
setup() {}
}
expect(ext.name).toBe('bc14.test.ext')
expect(ext.apiVersion).toBe('2')
expect(typeof ext.init).toBe('function')
expect(typeof ext.setup).toBe('function')
})
it('ExtensionOptions.name is required — an object without name fails the type check', () => {
// This is a compile-time guarantee; at runtime we assert the field is present.
const ext = { name: 'required', setup() {} } satisfies ExtensionOptions
expect(ext.name).toBeDefined()
})
it('[gap] ExtensionOptions does not yet have a beforePrompt field — Phase B addition', () => {
// beforePrompt / ctx.on('beforePrompt') is documented in D6 §Q4 but not yet on
// the interface. When Phase B lands, this test should be replaced by a real
// type-shape assertion on the handler signature.
const ext: ExtensionOptions = { name: 'bc14.gap.check' }
expect('beforePrompt' in ext).toBe(false)
})
})
describe('NodeExtensionOptions — current stable surface', () => {
it('NodeExtensionOptions accepts name, nodeTypes, nodeCreated, loadedGraphNode', () => {
const ext: NodeExtensionOptions = {
name: 'bc14.node.ext',
nodeTypes: ['SetNode', 'GetNode'],
nodeCreated(_node) {},
loadedGraphNode(_node) {}
}
expect(ext.name).toBe('bc14.node.ext')
expect(ext.nodeTypes).toEqual(['SetNode', 'GetNode'])
})
it('[gap] NodeExtensionOptions does not yet have virtual or resolveConnections — Phase B addition', () => {
// virtual:true + resolveConnections(node, graph) → edges[] is documented in D6 §Q5
// but not yet on the interface. KJNodes Set/Get pattern (S9.SG1) depends on this.
// Classification: uwf-resolved (UWF Phase 3 must know which nodes are layout-only).
const ext: NodeExtensionOptions = { name: 'bc14.virtual.gap' }
expect('virtual' in ext).toBe(false)
expect('resolveConnections' in ext).toBe(false)
})
})
})
// ── Phase B + UWF Phase 3 stubs ───────────────────────────────────────────────
describe('BC.14 v2 contract — beforePrompt runtime [Phase B + UWF Phase 3]', () => {
describe('ctx.on("beforePrompt", handler) — event registration', () => {
it.todo(
'[Phase B/C] ExtensionOptions accepts a setup() that calls ctx.on("beforePrompt", fn) inside the defineExtension scope context'
)
it.todo(
'[Phase B/C] beforePrompt handler receives a typed BeforePromptEvent with { spec, workflow } matching the UWF output shape'
)
it.todo(
'[Phase B/C] mutations to event.spec inside the handler are present in the API body sent to the backend'
)
it.todo(
'[Phase B/C] handler can reject the prompt via event.reject(reason), preventing queuePrompt from dispatching'
)
it.todo(
'[Phase B/C] multiple beforePrompt handlers registered across extensions fire in lexicographic name order (D10b)'
)
it.todo(
'[Phase B/C] each handler sees mutations made by prior handlers in the same event cycle'
)
})
describe('virtual:true + resolveConnections — KJNodes Set/Get class', () => {
it.todo(
'[Phase B/C] NodeExtensionOptions accepts virtual:true to mark a node type as layout-only (excluded from spec.edges)'
)
it.todo(
'[Phase B/C] NodeExtensionOptions accepts resolveConnections(node, graph) => ResolvedEdge[] for per-type connection resolution'
)
it.todo(
'[Phase B/C] resolveConnections receives a read-only graph view (mutations throw in dev mode)'
)
it.todo(
'[UWF Phase 3] virtual nodes absent from spec.edges after UWF Phase 3 save-time materialization runs'
)
it.todo(
'[UWF Phase 3] S9.SG1 Set/Get topology resolved by resolveConnections produces identical backend prompt to v1 graphToPrompt patch'
)
})
describe('cg-use-everywhere bridge (graph-wide topology, not per-type)', () => {
it.todo(
'[Phase B/C] ctx.on("beforePrompt") is the correct bridge for graph-wide type inference (not resolveConnections, which is per-type)'
)
})
})

View File

@@ -1,233 +0,0 @@
// 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
//
// Phase A strategy: prove that v1 interception (wrapping loadGraphData) and
// v2 interception (beforeLoadWorkflow handler) produce structurally equivalent
// outcomes on synthetic workflow fixtures. Shell rendering is todo(Phase B).
//
// I-TF.8.D2 — BC.15 migration wired assertions.
import { describe, expect, it } from 'vitest'
import { createMiniComfyApp } from '../harness'
// ── V1 app shim with loadGraphData ────────────────────────────────────────────
interface WorkflowJSON {
nodes: Array<{ id: number; type: string }>
links: unknown[]
}
function createV1App() {
const loadLog: WorkflowJSON[] = []
let _loadGraphData = (json: WorkflowJSON) => {
loadLog.push(json)
}
return {
get loadGraphData() {
return _loadGraphData
},
set loadGraphData(fn: (json: WorkflowJSON) => void) {
_loadGraphData = fn
},
get loadLog() {
return loadLog
},
callLoad(json: WorkflowJSON) {
_loadGraphData(json)
}
}
}
// ── V2 workflow loader (same as bc-15.v2) ────────────────────────────────────
interface BeforeLoadEvent {
workflow: WorkflowJSON
cancel(): void
}
interface AfterLoadEvent {
workflow: WorkflowJSON
nodeCount: number
}
function createV2Loader() {
const beforeHandlers: Array<(e: BeforeLoadEvent) => void> = []
const afterHandlers: Array<(e: AfterLoadEvent) => void> = []
const loadLog: WorkflowJSON[] = []
function on(
event: 'beforeLoadWorkflow',
h: (e: BeforeLoadEvent) => void
): () => void
function on(
event: 'afterLoadWorkflow',
h: (e: AfterLoadEvent) => void
): () => void
function on(event: string, h: (e: never) => void): () => void {
const arr =
event === 'beforeLoadWorkflow'
? beforeHandlers
: (afterHandlers as never[])
arr.push(h as never)
return () => {
const i = arr.indexOf(h as never)
if (i !== -1) arr.splice(i, 1)
}
}
async function loadWorkflow(
json: WorkflowJSON
): Promise<{ loaded: boolean }> {
let cancelled = false
const evt: BeforeLoadEvent = {
workflow: { ...json, nodes: [...json.nodes] },
cancel() {
cancelled = true
}
}
for (const h of [...beforeHandlers]) h(evt)
if (cancelled) return { loaded: false }
loadLog.push(evt.workflow)
const afterEvt: AfterLoadEvent = {
workflow: evt.workflow,
nodeCount: evt.workflow.nodes.length
}
for (const h of [...afterHandlers]) h(afterEvt)
return { loaded: true }
}
return { on, loadWorkflow, loadLog }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.15 migration — workflow loading', () => {
describe('load call-count parity', () => {
it('v1 loadGraphData and v2 loadWorkflow each called once per load invocation', async () => {
const v1 = createV1App()
const v2 = createV2Loader()
const workflow: WorkflowJSON = {
nodes: [{ id: 1, type: 'KSampler' }],
links: []
}
v1.callLoad(workflow)
await v2.loadWorkflow(workflow)
expect(v1.loadLog).toHaveLength(1)
expect(v2.loadLog).toHaveLength(1)
})
})
describe('interception migration — beforeLoad vs loadGraphData monkey-patch', () => {
it('v1 mutation via loadGraphData wrapper and v2 mutation via beforeLoadWorkflow both alter the loaded workflow', async () => {
const v1 = createV1App()
const v2 = createV2Loader()
const v1Seen: WorkflowJSON[] = []
const v2Seen: WorkflowJSON[] = []
// v1: wrap loadGraphData to inject a node
const origV1 = v1.loadGraphData
v1.loadGraphData = (json) => {
const mutated = {
...json,
nodes: [...json.nodes, { id: 99, type: 'injected' }]
}
v1Seen.push(mutated)
origV1(mutated)
}
// v2: beforeLoadWorkflow handler to inject a node
v2.on('beforeLoadWorkflow', (e) => {
e.workflow.nodes.push({ id: 99, type: 'injected' })
v2Seen.push({ ...e.workflow })
})
const base: WorkflowJSON = {
nodes: [{ id: 1, type: 'KSampler' }],
links: []
}
v1.callLoad(base)
await v2.loadWorkflow(base)
expect(v1Seen[0].nodes).toHaveLength(2)
expect(v2Seen[0].nodes).toHaveLength(2)
expect(v1Seen[0].nodes[1].type).toBe('injected')
expect(v2Seen[0].nodes[1].type).toBe('injected')
})
})
describe('cancellation migration', () => {
it('v1 no-op wrapper (skip orig call) and v2 event.cancel() both suppress the load', async () => {
const v1 = createV1App()
const v2 = createV2Loader()
// v1: wrapper that swallows the call
v1.loadGraphData = (_json) => {
/* intentionally empty — suppressed */
}
// v2: cancel via beforeLoadWorkflow
v2.on('beforeLoadWorkflow', (e) => e.cancel())
const workflow: WorkflowJSON = {
nodes: [{ id: 1, type: 'A' }],
links: []
}
v1.callLoad(workflow)
const { loaded } = await v2.loadWorkflow(workflow)
expect(v1.loadLog).toHaveLength(0) // inner original was not called
expect(loaded).toBe(false)
expect(v2.loadLog).toHaveLength(0)
})
})
describe('post-load logic migration', () => {
it('v1 synchronous code after loadGraphData and v2 afterLoadWorkflow handler both see the loaded state', async () => {
const v1App = createMiniComfyApp()
const v2 = createV2Loader()
const v1SeenCount: number[] = []
const v2SeenCount: number[] = []
// v1: synchronous post-load
const workflow: WorkflowJSON = {
nodes: [
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
links: []
}
for (const n of workflow.nodes) v1App.graph.add({ type: n.type })
v1SeenCount.push(v1App.world.allNodes().length)
// v2: afterLoadWorkflow handler
v2.on('afterLoadWorkflow', (e) => v2SeenCount.push(e.nodeCount))
await v2.loadWorkflow(workflow)
expect(v1SeenCount[0]).toBe(2)
expect(v2SeenCount[0]).toBe(2)
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.15 migration — workflow loading [Phase B / shell]', () => {
it.todo(
'[shell] v1 app.loadGraphData(json) and v2 app.loadWorkflow(json) produce identical canvas states for the same workflow'
)
it.todo(
'[shell] widget values are preserved identically between v1 and v2 load paths'
)
it.todo(
'[shell] custom node types registered by extensions are correctly hydrated by both load paths'
)
it.todo(
'[shell] calling v2 app.loadWorkflow does not break extensions that still listen on the legacy nodeCreated hook'
)
})

View File

@@ -1,110 +0,0 @@
// 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, expect, it } from 'vitest'
import {
countEvidenceExcerpts,
createMiniComfyApp,
loadEvidenceSnippet,
runV1
} from '../harness'
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.15 v1 contract — app.loadGraphData', () => {
// ── S6.A2 evidence ───────────────────────────────────────────────────────────
describe('S6.A2 — evidence excerpts', () => {
it('S6.A2 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S6.A2')).toBeGreaterThan(0)
})
it('S6.A2 evidence snippet contains loadGraphData fingerprint', () => {
const count = countEvidenceExcerpts('S6.A2')
let found = false
for (let i = 0; i < count; i++) {
const snippet = loadEvidenceSnippet('S6.A2', i)
if (/loadGraphData/i.test(snippet)) {
found = true
break
}
}
expect(
found,
'Expected at least one S6.A2 excerpt with loadGraphData fingerprint'
).toBe(true)
})
it('S6.A2 snippet is capturable by runV1 without throwing', () => {
const snippet = loadEvidenceSnippet('S6.A2', 0)
const app = createMiniComfyApp()
expect(() => runV1(snippet, { app })).not.toThrow()
})
})
// ── S6.A2 synthetic behavior ─────────────────────────────────────────────────
describe('S6.A2 — direct workflow load', () => {
it('loadGraphData replaces graph nodes with those from the provided JSON', () => {
const app = createMiniComfyApp()
app.graph.add({ type: 'KSampler' })
expect(app.world.allNodes()).toHaveLength(1)
// Simulate loadGraphData clearing the graph and loading new nodes
app.world.clear()
app.graph.add({ type: 'CLIPTextEncode' })
app.graph.add({ type: 'VAEDecode' })
expect(app.world.allNodes()).toHaveLength(2)
expect(app.world.findNodesByType('CLIPTextEncode')).toHaveLength(1)
})
it('calling loadGraphData clears all existing nodes first (world is empty mid-load)', () => {
const app = createMiniComfyApp()
app.graph.add({ type: 'KSampler' })
app.graph.add({ type: 'CLIPTextEncode' })
expect(app.world.allNodes()).toHaveLength(2)
// Simulate loadGraphData: first step is clear
app.world.clear()
expect(app.world.allNodes()).toHaveLength(0)
// Then new nodes are added
app.graph.add({ type: 'VAEDecode' })
expect(app.world.allNodes()).toHaveLength(1)
})
it('accepts a plain JSON object (not a string) — harness world.addNode accepts plain objects too', () => {
const app = createMiniComfyApp()
// The workflow is a plain object literal, not a JSON string
const workflowJson = {
nodes: [{ type: 'KSampler' }, { type: 'VAEDecode' }]
}
// Simulate loadGraphData: iterate the nodes array and add each
app.world.clear()
for (const nodeSpec of workflowJson.nodes) {
app.world.addNode({ type: nodeSpec.type })
}
expect(app.world.allNodes()).toHaveLength(2)
})
it('node IDs in the loaded workflow are preserved — use world to look up by type after add', () => {
const app = createMiniComfyApp()
app.world.clear()
// Add nodes with specific types; harness assigns sequential IDs
const id1 = app.world.addNode({ type: 'KSampler' })
const id2 = app.world.addNode({ type: 'CLIPTextEncode' })
// Verify that the nodes can be retrieved by their assigned IDs
expect(app.world.findNode(id1)?.type).toBe('KSampler')
expect(app.world.findNode(id2)?.type).toBe('CLIPTextEncode')
// Both IDs are distinct and stable
expect(id1).not.toBe(id2)
})
it.todo(
'real app.loadGraphData implementation: nodeCreated event fires for each deserialized node after loadGraphData completes'
)
it.todo(
'link preservation: edges between nodes are restored after loadGraphData'
)
})
})

View File

@@ -1,216 +0,0 @@
// 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
//
// Phase A strategy: test that the MiniComfyApp harness models the v2 load
// contract shape. Real graph deserialization and DOM effects need the shell
// integration (Phase B). Registration + hook firing order can be proved today
// with synthetic mocks.
//
// I-TF.8.D2 — BC.15 v2 wired assertions.
import { describe, expect, it, vi } from 'vitest'
import { createMiniComfyApp } from '../harness'
// ── Synthetic beforeLoad / afterLoad event bus ────────────────────────────────
// Models the app.on('beforeLoadWorkflow') / app.on('afterLoadWorkflow')
// registration contract without a real shell.
interface BeforeLoadEvent {
workflow: Record<string, unknown>
cancel(): void
}
interface AfterLoadEvent {
workflow: Record<string, unknown>
nodeCount: number
}
function createWorkflowLoader() {
const beforeHandlers: Array<(e: BeforeLoadEvent) => void> = []
const afterHandlers: Array<(e: AfterLoadEvent) => void> = []
function on(
event: 'beforeLoadWorkflow',
handler: (e: BeforeLoadEvent) => void
): () => void
function on(
event: 'afterLoadWorkflow',
handler: (e: AfterLoadEvent) => void
): () => void
function on(event: string, handler: (e: never) => void): () => void {
if (event === 'beforeLoadWorkflow') {
beforeHandlers.push(handler as (e: BeforeLoadEvent) => void)
return () => {
const i = beforeHandlers.indexOf(
handler as (e: BeforeLoadEvent) => void
)
if (i !== -1) beforeHandlers.splice(i, 1)
}
} else {
afterHandlers.push(handler as (e: AfterLoadEvent) => void)
return () => {
const i = afterHandlers.indexOf(handler as (e: AfterLoadEvent) => void)
if (i !== -1) afterHandlers.splice(i, 1)
}
}
}
async function loadWorkflow(
json: Record<string, unknown>
): Promise<{ loaded: boolean; nodeCount: number }> {
let cancelled = false
const beforeEvt: BeforeLoadEvent = {
workflow: { ...json },
cancel() {
cancelled = true
}
}
for (const h of [...beforeHandlers]) h(beforeEvt)
if (cancelled) return { loaded: false, nodeCount: 0 }
// Simulate deserialization: count nodes in workflow
const nodes = (beforeEvt.workflow.nodes as unknown[]) ?? []
const nodeCount = nodes.length
const afterEvt: AfterLoadEvent = { workflow: beforeEvt.workflow, nodeCount }
for (const h of [...afterHandlers]) h(afterEvt)
return { loaded: true, nodeCount }
}
return { on, loadWorkflow }
}
// ── Wired assertions (Phase A) ────────────────────────────────────────────────
describe('BC.15 v2 contract — app.loadWorkflow', () => {
describe('core load API shape', () => {
it('loadWorkflow returns a Promise', async () => {
const loader = createWorkflowLoader()
const result = loader.loadWorkflow({ nodes: [], links: [] })
expect(result).toBeInstanceOf(Promise)
await result
})
it('loadWorkflow resolves with loaded: true and the node count for a valid workflow', async () => {
const loader = createWorkflowLoader()
const { loaded, nodeCount } = await loader.loadWorkflow({
nodes: [{ id: 1 }, { id: 2 }, { id: 3 }],
links: []
})
expect(loaded).toBe(true)
expect(nodeCount).toBe(3)
})
it('loadWorkflow resolves with loaded: false and nodeCount 0 when cancelled', async () => {
const loader = createWorkflowLoader()
loader.on('beforeLoadWorkflow', (e) => e.cancel())
const { loaded, nodeCount } = await loader.loadWorkflow({
nodes: [{ id: 1 }],
links: []
})
expect(loaded).toBe(false)
expect(nodeCount).toBe(0)
})
it('MiniComfyApp.graph is present and has add/remove/findNodesByType', () => {
const app = createMiniComfyApp()
expect(typeof app.graph.add).toBe('function')
expect(typeof app.graph.remove).toBe('function')
expect(typeof app.graph.findNodesByType).toBe('function')
})
})
describe('beforeLoadWorkflow hook', () => {
it('on("beforeLoadWorkflow", handler) returns an unsubscribe function', () => {
const loader = createWorkflowLoader()
const unsub = loader.on('beforeLoadWorkflow', () => {})
expect(typeof unsub).toBe('function')
})
it('beforeLoadWorkflow handler fires before deserialization', async () => {
const loader = createWorkflowLoader()
const order: string[] = []
loader.on('beforeLoadWorkflow', () => order.push('before'))
await loader.loadWorkflow({ nodes: [], links: [] })
// 'after' fires in afterLoad — before must be first
order.push('load-done')
expect(order[0]).toBe('before')
})
it('handler can mutate event.workflow before deserialization', async () => {
const loader = createWorkflowLoader()
loader.on('beforeLoadWorkflow', (e) => {
e.workflow.nodes = [{ id: 99, type: 'injected' }]
})
const { nodeCount } = await loader.loadWorkflow({ nodes: [], links: [] })
expect(nodeCount).toBe(1)
})
it('calling event.cancel() prevents afterLoadWorkflow from firing', async () => {
const loader = createWorkflowLoader()
const afterHandler = vi.fn()
loader.on('beforeLoadWorkflow', (e) => e.cancel())
loader.on('afterLoadWorkflow', afterHandler)
await loader.loadWorkflow({ nodes: [], links: [] })
expect(afterHandler).not.toHaveBeenCalled()
})
it('unsubscribing a beforeLoadWorkflow handler stops it from firing', async () => {
const loader = createWorkflowLoader()
const handler = vi.fn()
const unsub = loader.on('beforeLoadWorkflow', handler)
unsub()
await loader.loadWorkflow({ nodes: [], links: [] })
expect(handler).not.toHaveBeenCalled()
})
})
describe('afterLoadWorkflow hook', () => {
it('on("afterLoadWorkflow", handler) returns an unsubscribe function', () => {
const loader = createWorkflowLoader()
const unsub = loader.on('afterLoadWorkflow', () => {})
expect(typeof unsub).toBe('function')
})
it('afterLoadWorkflow fires after deserialization with the original workflow and node count', async () => {
const loader = createWorkflowLoader()
let receivedNodeCount = -1
loader.on('afterLoadWorkflow', (e) => {
receivedNodeCount = e.nodeCount
})
await loader.loadWorkflow({ nodes: [{ id: 1 }, { id: 2 }], links: [] })
expect(receivedNodeCount).toBe(2)
})
it('multiple afterLoadWorkflow handlers all fire in registration order', async () => {
const loader = createWorkflowLoader()
const order: string[] = []
loader.on('afterLoadWorkflow', () => order.push('first'))
loader.on('afterLoadWorkflow', () => order.push('second'))
await loader.loadWorkflow({ nodes: [], links: [] })
expect(order).toEqual(['first', 'second'])
})
})
})
// ── Phase B stubs — shell integration ────────────────────────────────────────
describe('BC.15 v2 contract — app.loadWorkflow [Phase B / shell]', () => {
it.todo(
'[shell] app.loadWorkflow(json) deserializes all node types and renders them to the canvas'
)
it.todo(
'[shell] app.loadWorkflow(json) accepts a JSON string as well as a plain object'
)
it.todo(
'[shell] widget values are fully restored and match the serialized values in the workflow JSON'
)
it.todo(
'[shell] custom node types registered by extensions are correctly hydrated during loadWorkflow'
)
})

View File

@@ -1,179 +0,0 @@
// Category: BC.16 — Execution output consumption (per-node)
// DB cross-ref: S2.N2
// blast_radius: 4.67 (compat-floor)
// Migration: v1 node.onExecuted = fn → v2 NodeHandle.on('executed', fn)
//
// Phase A strategy: prove that v1 assignment and v2 on() registration
// both capture and expose the same event payload structure, using
// synthetic dispatch. Real WebSocket timing is todo(Phase B).
//
// I-TF.8.D2 — BC.16 migration wired assertions.
import { describe, expect, it, vi } from 'vitest'
import type { NodeExecutedEvent } from '@/extension-api/node'
// ── V1 node shim ──────────────────────────────────────────────────────────────
interface V1NodeLike {
onExecuted?: (data: { text?: string[]; images?: unknown[] }) => void
}
function createV1Node(): V1NodeLike & {
simulateExecuted(data: { text?: string[]; images?: unknown[] }): void
} {
const node: V1NodeLike = {}
return {
get onExecuted() {
return node.onExecuted
},
set onExecuted(fn) {
node.onExecuted = fn
},
simulateExecuted(data) {
node.onExecuted?.(data)
}
}
}
// ── V2 event bus (same minimal shape as bc-16.v2) ────────────────────────────
function createV2Bus() {
const handlers: Array<(e: NodeExecutedEvent) => void> = []
return {
on(_evt: 'executed', fn: (e: NodeExecutedEvent) => void) {
handlers.push(fn)
return () => {
const i = handlers.indexOf(fn)
if (i !== -1) handlers.splice(i, 1)
}
},
emit(e: NodeExecutedEvent) {
for (const h of [...handlers]) h(e)
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.16 migration — per-node execution output', () => {
describe('data shape equivalence', () => {
it('v1 onExecuted data.text and v2 executed event.output.text carry the same content', () => {
const v1 = createV1Node()
const v2 = createV2Bus()
const v1Texts: string[][] = []
const v2Texts: string[][] = []
v1.onExecuted = (data) => {
if (data.text) v1Texts.push(data.text)
}
v2.on('executed', (e) => {
if (e.output.text) v2Texts.push(e.output.text as string[])
})
const payload = { text: ['Generated text output'], images: [] }
v1.simulateExecuted(payload)
v2.emit({ output: payload })
expect(v1Texts[0]).toEqual(v2Texts[0])
})
it('v1 data.images and v2 event.output.images have the same length', () => {
const v1 = createV1Node()
const v2 = createV2Bus()
let v1ImageCount = -1
let v2ImageCount = -1
v1.onExecuted = (data) => {
v1ImageCount = data.images?.length ?? 0
}
v2.on('executed', (e) => {
v2ImageCount = (e.output.images as unknown[] | undefined)?.length ?? 0
})
const images = [{ filename: 'a.png', subfolder: '', type: 'output' }]
v1.simulateExecuted({ text: [], images })
v2.emit({ output: { text: [], images } })
expect(v1ImageCount).toBe(v2ImageCount)
})
})
describe('subscription model migration', () => {
it('v1 onExecuted assignment and v2 on() both register exactly one active handler', () => {
const v1 = createV1Node()
const v2 = createV2Bus()
const v1Handler = vi.fn()
const v2Handler = vi.fn()
v1.onExecuted = v1Handler
v2.on('executed', v2Handler)
const data = { text: ['x'], images: [] }
v1.simulateExecuted(data)
v2.emit({ output: data })
expect(v1Handler).toHaveBeenCalledOnce()
expect(v2Handler).toHaveBeenCalledOnce()
})
it('v1 reassignment replaces the handler; v2 unsubscribe + re-on is the equivalent', () => {
const v1 = createV1Node()
const v2 = createV2Bus()
const firstV1 = vi.fn()
const secondV1 = vi.fn()
const firstV2 = vi.fn()
const secondV2 = vi.fn()
v1.onExecuted = firstV1
const unsub = v2.on('executed', firstV2)
// Replace v1 handler
v1.onExecuted = secondV1
// Replace v2 handler
unsub()
v2.on('executed', secondV2)
const data = { text: [], images: [] }
v1.simulateExecuted(data)
v2.emit({ output: data })
expect(firstV1).not.toHaveBeenCalled()
expect(secondV1).toHaveBeenCalledOnce()
expect(firstV2).not.toHaveBeenCalled()
expect(secondV2).toHaveBeenCalledOnce()
})
})
describe('automatic cleanup advantage of v2', () => {
it('v1 onExecuted persists after explicit removal from tracking; v2 unsubscribe removes it cleanly', () => {
const v1 = createV1Node()
const v2 = createV2Bus()
const v1Handler = vi.fn()
const v2Handler = vi.fn()
v1.onExecuted = v1Handler
const unsub = v2.on('executed', v2Handler)
// v2: explicit unsubscribe
unsub()
const data = { text: [], images: [] }
v1.simulateExecuted(data) // v1 still fires (no automatic cleanup in v1)
v2.emit({ output: data }) // v2 handler removed
expect(v1Handler).toHaveBeenCalledOnce()
expect(v2Handler).not.toHaveBeenCalled()
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.16 migration — per-node execution output [Phase B / shell]', () => {
it.todo(
'[Phase B/C] v1 onExecuted and v2 on("executed") fire at the same point in WebSocket message processing'
)
it.todo(
'[Phase B/C] v2 on("executed") is automatically cleaned up on node removal; v1 leaks the assignment'
)
})

View File

@@ -1,75 +0,0 @@
// Category: BC.16 — Execution output consumption (per-node)
// DB cross-ref: S2.N2
// blast_radius: 4.67 (compat-floor)
// v1 contract: node.onExecuted(output) — prototype-patched per extension
// TODO(R8): swap with loadEvidenceSnippet('S2.N2', 0) once excerpts populated
import { describe, expect, it } from 'vitest'
import { countEvidenceExcerpts, loadEvidenceSnippet, runV1 } from '../harness'
void [loadEvidenceSnippet, runV1]
describe('BC.16 v1 contract — node.onExecuted callback (S2.N2)', () => {
it('S2.N2 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S2.N2')).toBeGreaterThan(0)
})
it('onExecuted receives the output object with arbitrary keys', () => {
const output = {
images: [{ filename: 'out.png', subfolder: '', type: 'output' }]
}
let received: unknown
const node = {
onExecuted(o: unknown) {
received = o
}
}
node.onExecuted(output)
expect((received as typeof output).images[0].filename).toBe('out.png')
})
it('onExecuted can be prototype-patched; the original is still callable', () => {
const log: string[] = []
const proto = {
onExecuted(_o: unknown) {
log.push('orig')
}
}
const orig = proto.onExecuted.bind(proto)
proto.onExecuted = function (o: unknown) {
log.push('ext')
orig(o)
}
proto.onExecuted({ text: ['hi'] })
expect(log).toEqual(['ext', 'orig'])
})
it('multiple extensions chain onExecuted; all fire in outer-first order', () => {
const log: number[] = []
let fn: (o: unknown) => void = () => {
log.push(0)
}
fn = ((prev) => (o: unknown) => {
log.push(1)
prev(o)
})(fn)
fn = ((prev) => (o: unknown) => {
log.push(2)
prev(o)
})(fn)
fn({})
expect(log).toEqual([2, 1, 0])
})
it('output object shape for text-type nodes has a text array', () => {
const output: Record<string, unknown> = { text: ['result string'] }
const keys: string[] = []
const node = {
onExecuted(o: Record<string, unknown>) {
keys.push(...Object.keys(o))
}
}
node.onExecuted(output)
expect(keys).toContain('text')
})
})

View File

@@ -1,192 +0,0 @@
// 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', handler)
//
// Phase A strategy: prove the on('executed') registration contract and
// NodeExecutedEvent payload shape using a minimal typed event bus.
// Real WebSocket delivery needs Phase B shell integration.
//
// I-TF.8.D2 — BC.16 v2 wired assertions.
import { describe, expect, it, vi } from 'vitest'
import type { NodeExecutedEvent } from '@/extension-api/node'
import type { Unsubscribe } from '@/extension-api/events'
// ── Minimal executed event bus ────────────────────────────────────────────────
function createExecutedBus() {
const handlers: Array<(e: NodeExecutedEvent) => void> = []
function on(
_event: 'executed',
handler: (e: NodeExecutedEvent) => void
): Unsubscribe {
handlers.push(handler)
return () => {
const i = handlers.indexOf(handler)
if (i !== -1) handlers.splice(i, 1)
}
}
function emit(event: NodeExecutedEvent) {
for (const h of [...handlers]) h(event)
}
return { on, emit, handlerCount: () => handlers.length }
}
// ── Fixture ───────────────────────────────────────────────────────────────────
function makeExecutedEvent(
overrides: Partial<NodeExecutedEvent> = {}
): NodeExecutedEvent {
return {
output: { text: ['hello world'], images: [] },
...overrides
}
}
// ── Wired assertions ──────────────────────────────────────────────────────────
describe('BC.16 v2 contract — NodeHandle executed event', () => {
describe('event subscription shape', () => {
it('on("executed", fn) returns an Unsubscribe function', () => {
const bus = createExecutedBus()
const unsub = bus.on('executed', () => {})
expect(typeof unsub).toBe('function')
})
it('registered handler is called when an executed event fires', () => {
const bus = createExecutedBus()
const handler = vi.fn()
bus.on('executed', handler)
bus.emit(makeExecutedEvent())
expect(handler).toHaveBeenCalledOnce()
})
it('handler receives a NodeExecutedEvent with an output field', () => {
const bus = createExecutedBus()
let received: NodeExecutedEvent | undefined
bus.on('executed', (e) => {
received = e
})
bus.emit(makeExecutedEvent({ output: { text: ['result'], images: [] } }))
expect(received).toBeDefined()
expect(received!.output).toBeDefined()
})
it('calling Unsubscribe stops future executed events from reaching the handler', () => {
const bus = createExecutedBus()
const handler = vi.fn()
const unsub = bus.on('executed', handler)
bus.emit(makeExecutedEvent())
expect(handler).toHaveBeenCalledOnce()
unsub()
bus.emit(makeExecutedEvent())
expect(handler).toHaveBeenCalledOnce() // no additional call
})
it('calling Unsubscribe twice is safe', () => {
const bus = createExecutedBus()
const unsub = bus.on('executed', vi.fn())
expect(() => {
unsub()
unsub()
}).not.toThrow()
})
})
describe('NodeExecutedEvent payload shape', () => {
it('event.output.text is an array (string[] for text-output nodes)', () => {
const bus = createExecutedBus()
let output: NodeExecutedEvent['output'] | undefined
bus.on('executed', (e) => {
output = e.output
})
bus.emit(
makeExecutedEvent({ output: { text: ['line1', 'line2'], images: [] } })
)
expect(Array.isArray(output!.text)).toBe(true)
expect(output!.text).toEqual(['line1', 'line2'])
})
it('event.output.images is an array', () => {
const bus = createExecutedBus()
let output: NodeExecutedEvent['output'] | undefined
bus.on('executed', (e) => {
output = e.output
})
bus.emit(makeExecutedEvent({ output: { text: [], images: [] } }))
expect(Array.isArray(output!.images)).toBe(true)
})
it('output fields are accessible without a cast from within the handler', () => {
// Type-level: NodeExecutedEvent.output.text should be string[] — compile-time.
// Runtime: values are accessible as typed properties.
const bus = createExecutedBus()
const texts: string[] = []
bus.on('executed', (e) => {
for (const t of (e.output.text as string[] | undefined) ?? [])
texts.push(t)
})
bus.emit(
makeExecutedEvent({ output: { text: ['alpha', 'beta'], images: [] } })
)
expect(texts).toEqual(['alpha', 'beta'])
})
})
describe('multiple handlers', () => {
it('multiple on("executed") handlers all fire independently', () => {
const bus = createExecutedBus()
const handlerA = vi.fn()
const handlerB = vi.fn()
bus.on('executed', handlerA)
bus.on('executed', handlerB)
bus.emit(makeExecutedEvent())
expect(handlerA).toHaveBeenCalledOnce()
expect(handlerB).toHaveBeenCalledOnce()
})
it('unsubscribing one handler does not affect the others', () => {
const bus = createExecutedBus()
const handlerA = vi.fn()
const handlerB = vi.fn()
const unsubA = bus.on('executed', handlerA)
bus.on('executed', handlerB)
unsubA()
bus.emit(makeExecutedEvent())
expect(handlerA).not.toHaveBeenCalled()
expect(handlerB).toHaveBeenCalledOnce()
})
})
describe('handler lifecycle with scope', () => {
it('after all handlers are unsubscribed, the bus has zero active handlers', () => {
const bus = createExecutedBus()
const unsubA = bus.on('executed', vi.fn())
const unsubB = bus.on('executed', vi.fn())
expect(bus.handlerCount()).toBe(2)
unsubA()
unsubB()
expect(bus.handlerCount()).toBe(0)
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.16 v2 contract — NodeHandle executed event [Phase B / shell]', () => {
it.todo(
'[Phase B/C] NodeHandle.on("executed") fires when the real WebSocket executed message arrives for this node'
)
it.todo(
'[Phase B/C] handlers registered via on("executed") are automatically removed when the node is removed from the World'
)
it.todo(
'[Phase B/C] output.images includes filename, subfolder, and type fields matching the backend response schema'
)
})

View File

@@ -1,185 +0,0 @@
// Category: BC.17 — Backend execution lifecycle and progress events
// DB cross-ref: S5.A1, S5.A2, S5.A3
// blast_radius: 5.00 (compat-floor)
// Migration: v1 app.api.addEventListener → v2 comfyApp.on with typed payloads
//
// Phase A strategy: prove that v1 CustomEvent-style registration and v2 on()
// registration both capture and expose the same payload structure for each
// event type, using synthetic dispatch. Real WebSocket timing is todo(Phase B).
//
// I-TF.8.D2 — BC.17 migration wired assertions.
import { describe, expect, it, vi } from 'vitest'
// ── V1 event bus (CustomEvent-style addEventListener) ─────────────────────────
function createV1Api() {
const listeners = new Map<string, EventListenerOrEventListenerObject[]>()
return {
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject
) {
if (!listeners.has(type)) listeners.set(type, [])
listeners.get(type)!.push(listener)
},
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject
) {
const arr = listeners.get(type)
if (arr) {
const i = arr.indexOf(listener)
if (i !== -1) arr.splice(i, 1)
}
},
dispatchCustom(type: string, detail: unknown) {
const event = { type, detail } as unknown as CustomEvent
for (const l of [...(listeners.get(type) ?? [])]) {
if (typeof l === 'function') l(event)
else (l as EventListenerObject).handleEvent(event)
}
}
}
}
// ── V2 app event bus ──────────────────────────────────────────────────────────
function createV2Bus() {
const handlers = new Map<string, Array<(e: unknown) => void>>()
function on(event: string, handler: (e: unknown) => void): () => void {
if (!handlers.has(event)) handlers.set(event, [])
handlers.get(event)!.push(handler)
return () => {
const arr = handlers.get(event)!
const i = arr.indexOf(handler)
if (i !== -1) arr.splice(i, 1)
}
}
function emit(event: string, payload: unknown) {
for (const h of [...(handlers.get(event) ?? [])]) h(payload)
}
return { on, emit }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.17 migration — execution lifecycle events', () => {
describe('S5.A1 — executed / executionError payload equivalence', () => {
it('v1 executed detail and v2 executed payload carry the same nodeId and output', () => {
const v1Api = createV1Api()
const v2 = createV2Bus()
const v1Received: unknown[] = []
const v2Received: unknown[] = []
v1Api.addEventListener('executed', ((e: CustomEvent) =>
v1Received.push(e.detail)) as unknown as EventListener)
v2.on('executed', (e) => v2Received.push(e))
const payload = { nodeId: 'node:g:1', output: { text: ['hello'] } }
v1Api.dispatchCustom('executed', payload)
v2.emit('executed', payload)
expect(v1Received[0]).toEqual(v2Received[0])
})
it('v1 execution_error and v2 executionError carry the same nodeId and message', () => {
const v1Api = createV1Api()
const v2 = createV2Bus()
const v1Detail: unknown[] = []
const v2Payload: unknown[] = []
v1Api.addEventListener('execution_error', ((e: CustomEvent) =>
v1Detail.push(e.detail)) as unknown as EventListener)
v2.on('executionError', (e) => v2Payload.push(e))
const payload = { nodeId: 'node:g:7', message: 'CUDA OOM' }
v1Api.dispatchCustom('execution_error', payload)
v2.emit('executionError', payload)
const v1 = v1Detail[0] as typeof payload
const v2p = v2Payload[0] as typeof payload
expect(v1.nodeId).toBe(v2p.nodeId)
expect(v1.message).toBe(v2p.message)
})
})
describe('S5.A2 — progress payload equivalence', () => {
it('v1 progress {value, max} and v2 progress {step, totalSteps} encode the same completion fraction', () => {
// v1 shape: { value: number, max: number }
// v2 shape: { step: number, totalSteps: number }
const v1Fractions: number[] = []
const v2Fractions: number[] = []
const v1Api = createV1Api()
const v2 = createV2Bus()
v1Api.addEventListener('progress', ((e: CustomEvent) => {
const d = e.detail as { value: number; max: number }
v1Fractions.push(d.value / d.max)
}) as EventListener)
v2.on('progress', (e) => {
const p = e as { step: number; totalSteps: number }
v2Fractions.push(p.step / p.totalSteps)
})
v1Api.dispatchCustom('progress', { value: 8, max: 20 })
v2.emit('progress', { step: 8, totalSteps: 20, nodeId: 'node:g:1' })
expect(v1Fractions[0]).toBeCloseTo(v2Fractions[0])
})
})
describe('handler removal equivalence', () => {
it('v1 removeEventListener and v2 unsubscribe() both prevent subsequent events from reaching the handler', () => {
const v1Api = createV1Api()
const v2 = createV2Bus()
const v1Handler = vi.fn() as EventListenerOrEventListenerObject
const v2Handler = vi.fn()
v1Api.addEventListener('status', v1Handler)
const unsub = v2.on('status', v2Handler)
// Remove both
v1Api.removeEventListener('status', v1Handler)
unsub()
v1Api.dispatchCustom('status', { queueRemaining: 0 })
v2.emit('status', { queueRemaining: 0, running: false })
expect(v1Handler).not.toHaveBeenCalled()
expect(v2Handler).not.toHaveBeenCalled()
})
it('removing a v1 listener does not affect a concurrently registered v2 listener', () => {
const v1Api = createV1Api()
const v2 = createV2Bus()
const v1Handler = vi.fn() as EventListenerOrEventListenerObject
const v2Handler = vi.fn()
v1Api.addEventListener('status', v1Handler)
v2.on('status', v2Handler)
v1Api.removeEventListener('status', v1Handler)
v2.emit('status', { queueRemaining: 1, running: true })
expect(v2Handler).toHaveBeenCalledOnce()
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.17 migration — execution lifecycle events [Phase B / shell]', () => {
it.todo(
'[Phase B/C] v1 app.api.addEventListener("executed") and v2 on("executed") fire at the same point in WebSocket processing'
)
it.todo(
'[Phase B/C] v1 "reconnecting" and v2 "reconnecting" both fire before the first reconnect attempt'
)
})

View File

@@ -1,69 +0,0 @@
// Category: BC.17 — Backend execution lifecycle and progress events
// DB cross-ref: S5.A1, S5.A2, S5.A3
// blast_radius: 5.00 (compat-floor)
// v1 contract: api.addEventListener('executed'|'progress'|'executing', fn)
// TODO(R8): swap with loadEvidenceSnippet once excerpts populated
import { describe, expect, it } from 'vitest'
import { countEvidenceExcerpts, loadEvidenceSnippet, runV1 } from '../harness'
void [loadEvidenceSnippet, runV1]
function makeApi() {
const listeners = new Map<string, Array<(e: { detail: unknown }) => void>>()
return {
addEventListener(event: string, fn: (e: { detail: unknown }) => void) {
if (!listeners.has(event)) listeners.set(event, [])
listeners.get(event)!.push(fn)
},
_emit(event: string, detail: unknown) {
listeners.get(event)?.forEach((fn) => fn({ detail }))
}
}
}
describe('BC.17 v1 contract — backend execution lifecycle events (S5.A1/A2/A3)', () => {
it('S5.A1 has at least one evidence excerpt', () => {
expect(countEvidenceExcerpts('S5.A1')).toBeGreaterThan(0)
})
it("addEventListener('executed') fires with detail.node and detail.output", () => {
const api = makeApi()
let detail: unknown
api.addEventListener('executed', (e) => {
detail = e.detail
})
api._emit('executed', { node: '5', output: { images: [] } })
expect((detail as { node: string }).node).toBe('5')
})
it("addEventListener('progress') fires with detail.value and detail.max", () => {
const api = makeApi()
let detail: unknown
api.addEventListener('progress', (e) => {
detail = e.detail
})
api._emit('progress', { value: 3, max: 10 })
expect((detail as { value: number; max: number }).value).toBe(3)
expect((detail as { value: number; max: number }).max).toBe(10)
})
it("addEventListener('executing') fires with currently-running node id", () => {
const api = makeApi()
const ids: unknown[] = []
api.addEventListener('executing', (e) =>
ids.push((e.detail as { node: string }).node)
)
api._emit('executing', { node: '7' })
expect(ids).toEqual(['7'])
})
it('multiple listeners on the same event all fire', () => {
const api = makeApi()
const log: number[] = []
api.addEventListener('executed', () => log.push(1))
api.addEventListener('executed', () => log.push(2))
api._emit('executed', {})
expect(log).toEqual([1, 2])
})
})

View File

@@ -1,233 +0,0 @@
// 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
//
// Phase A strategy: prove the registration contract (on() returns Unsubscribe,
// handlers fire when emitted, multiple handlers are independent) using a
// synthetic typed app-level event bus. Real WebSocket delivery is todo(Phase B).
//
// I-TF.8.D2 — BC.17 v2 wired assertions.
import { describe, expect, it, vi } from 'vitest'
import type { Unsubscribe } from '@/extension-api/events'
// ── Typed payload shapes (mirrors what the real shell will emit) ──────────────
interface ExecutedPayload {
nodeId: string
output: Record<string, unknown>
}
interface ExecutionErrorPayload {
nodeId: string
message: string
}
interface ExecutionStartPayload {
promptId: string
}
interface ProgressPayload {
step: number
totalSteps: number
nodeId: string
}
interface StatusPayload {
queueRemaining: number
running: boolean
}
interface ReconnectingPayload {
attempt: number
}
type AppEventMap = {
executed: ExecutedPayload
executionError: ExecutionErrorPayload
executionStart: ExecutionStartPayload
progress: ProgressPayload
status: StatusPayload
reconnecting: ReconnectingPayload
}
// ── Minimal typed app event bus ───────────────────────────────────────────────
function createAppEventBus() {
const handlers = new Map<string, Array<(e: unknown) => void>>()
function on<K extends keyof AppEventMap>(
event: K,
handler: (e: AppEventMap[K]) => void
): Unsubscribe {
if (!handlers.has(event)) handlers.set(event, [])
const arr = handlers.get(event)!
arr.push(handler as (e: unknown) => void)
return () => {
const i = arr.indexOf(handler as (e: unknown) => void)
if (i !== -1) arr.splice(i, 1)
}
}
function emit<K extends keyof AppEventMap>(
event: K,
payload: AppEventMap[K]
) {
for (const h of [...(handlers.get(event) ?? [])]) h(payload)
}
function handlerCount(event: string) {
return handlers.get(event)?.length ?? 0
}
return { on, emit, handlerCount }
}
// ── Wired assertions ──────────────────────────────────────────────────────────
describe('BC.17 v2 contract — comfyApp event subscriptions', () => {
describe('S5.A1 — execution lifecycle events', () => {
it('on("executed", fn) returns an Unsubscribe function', () => {
const bus = createAppEventBus()
const unsub = bus.on('executed', () => {})
expect(typeof unsub).toBe('function')
})
it('on("executed") handler fires with typed { nodeId, output } payload', () => {
const bus = createAppEventBus()
let received: ExecutedPayload | undefined
bus.on('executed', (e) => {
received = e
})
bus.emit('executed', { nodeId: 'node:g:42', output: { text: ['hi'] } })
expect(received).toBeDefined()
expect(received!.nodeId).toBe('node:g:42')
expect(received!.output.text).toEqual(['hi'])
})
it('on("executionError") handler fires with typed { nodeId, message } payload', () => {
const bus = createAppEventBus()
let received: ExecutionErrorPayload | undefined
bus.on('executionError', (e) => {
received = e
})
bus.emit('executionError', { nodeId: 'node:g:7', message: 'CUDA OOM' })
expect(received!.nodeId).toBe('node:g:7')
expect(received!.message).toBe('CUDA OOM')
})
it('on("executionStart") handler fires with typed { promptId } payload', () => {
const bus = createAppEventBus()
let received: ExecutionStartPayload | undefined
bus.on('executionStart', (e) => {
received = e
})
bus.emit('executionStart', { promptId: 'abc-123' })
expect(received!.promptId).toBe('abc-123')
})
})
describe('S5.A2 — progress events', () => {
it('on("progress") handler fires with typed { step, totalSteps, nodeId } payload', () => {
const bus = createAppEventBus()
let received: ProgressPayload | undefined
bus.on('progress', (e) => {
received = e
})
bus.emit('progress', { step: 5, totalSteps: 20, nodeId: 'node:g:1' })
expect(received!.step).toBe(5)
expect(received!.totalSteps).toBe(20)
expect(received!.nodeId).toBe('node:g:1')
})
it('progress percentage (step / totalSteps) encodes the same fraction as v1 (value / max)', () => {
const bus = createAppEventBus()
const fractions: number[] = []
bus.on('progress', (e) => fractions.push(e.step / e.totalSteps))
bus.emit('progress', { step: 10, totalSteps: 20, nodeId: 'node:g:1' })
bus.emit('progress', { step: 20, totalSteps: 20, nodeId: 'node:g:1' })
expect(fractions[0]).toBeCloseTo(0.5)
expect(fractions[1]).toBeCloseTo(1.0)
})
})
describe('S5.A3 — status and connectivity events', () => {
it('on("status") handler fires with typed { queueRemaining, running } payload', () => {
const bus = createAppEventBus()
let received: StatusPayload | undefined
bus.on('status', (e) => {
received = e
})
bus.emit('status', { queueRemaining: 3, running: true })
expect(received!.queueRemaining).toBe(3)
expect(received!.running).toBe(true)
})
it('on("reconnecting") handler fires with typed { attempt } payload', () => {
const bus = createAppEventBus()
let received: ReconnectingPayload | undefined
bus.on('reconnecting', (e) => {
received = e
})
bus.emit('reconnecting', { attempt: 1 })
expect(received!.attempt).toBe(1)
})
it('Unsubscribe returned by on() removes the handler', () => {
const bus = createAppEventBus()
const handler = vi.fn()
const unsub = bus.on('status', handler)
bus.emit('status', { queueRemaining: 0, running: false })
expect(handler).toHaveBeenCalledOnce()
unsub()
bus.emit('status', { queueRemaining: 0, running: false })
expect(handler).toHaveBeenCalledOnce() // no new call
})
it('unsubscribing one handler does not affect other subscribers on the same event', () => {
const bus = createAppEventBus()
const handlerA = vi.fn()
const handlerB = vi.fn()
const unsubA = bus.on('status', handlerA)
bus.on('status', handlerB)
unsubA()
bus.emit('status', { queueRemaining: 1, running: true })
expect(handlerA).not.toHaveBeenCalled()
expect(handlerB).toHaveBeenCalledOnce()
})
it('calling Unsubscribe twice does not throw', () => {
const bus = createAppEventBus()
const unsub = bus.on('reconnecting', vi.fn())
expect(() => {
unsub()
unsub()
}).not.toThrow()
})
})
describe('cross-event independence', () => {
it('"executed" handler does not fire when "progress" is emitted', () => {
const bus = createAppEventBus()
const executedHandler = vi.fn()
bus.on('executed', executedHandler)
bus.emit('progress', { step: 1, totalSteps: 10, nodeId: 'node:g:1' })
expect(executedHandler).not.toHaveBeenCalled()
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.17 v2 contract — comfyApp events [Phase B / shell]', () => {
it.todo(
'[Phase B/C] on("executed") fires when the real WebSocket "executed" message arrives'
)
it.todo(
'[Phase B/C] on("progress") fires on each step tick from the real backend'
)
it.todo(
'[Phase B/C] on("status") fires when queue depth or running state changes via WebSocket'
)
it.todo(
'[Phase B/C] on("reconnecting") fires before the first reconnect attempt after connection loss'
)
})

View File

@@ -1,150 +0,0 @@
// Category: BC.18 — Backend HTTP calls
// DB cross-ref: S6.A3
// blast_radius: 5.77 (compat-floor)
// Migration: v1 app.api.fetchApi → v2 comfyAPI.fetchApi (same signature, stable import)
//
// Phase A strategy: prove that v1 and v2 both build identical HTTP requests
// from the same inputs, using a fetch mock. Real auth and base-URL behavior
// is todo(Phase B / shell).
//
// I-TF.8.D2 — BC.18 migration wired assertions.
import { describe, expect, it, vi, afterEach } from 'vitest'
// ── V1 app.api shim ───────────────────────────────────────────────────────────
function createV1Api(baseUrl = 'http://localhost:8188') {
return {
async fetchApi(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(`${baseUrl}${path}`, init)
}
}
}
// ── V2 comfyAPI shim ──────────────────────────────────────────────────────────
function createV2ComfyAPI(baseUrl = 'http://localhost:8188') {
return {
async fetchApi(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(`${baseUrl}${path}`, init)
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('BC.18 migration — backend HTTP calls', () => {
afterEach(() => vi.restoreAllMocks())
describe('request equivalence', () => {
it('v1 app.api.fetchApi and v2 comfyAPI.fetchApi call fetch with the same URL', async () => {
const mockFetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }))
const v1 = createV1Api()
const v2 = createV2ComfyAPI()
await v1.fetchApi('/api/history')
const v1Url = mockFetch.mock.calls[0][0]
mockFetch.mockClear()
await v2.fetchApi('/api/history')
const v2Url = mockFetch.mock.calls[0][0]
expect(v1Url).toBe(v2Url)
})
it('v1 and v2 both pass RequestInit through to fetch unchanged', async () => {
const mockFetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }))
const v1 = createV1Api()
const v2 = createV2ComfyAPI()
const init: RequestInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"a":1}'
}
await v1.fetchApi('/api/prompt', init)
const v1Init = mockFetch.mock.calls[0][1]
mockFetch.mockClear()
await v2.fetchApi('/api/prompt', init)
const v2Init = mockFetch.mock.calls[0][1]
expect(v1Init).toEqual(v2Init)
})
it('FormData uploads produce the same body reference in both v1 and v2', async () => {
const mockFetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }))
const v1 = createV1Api()
const v2 = createV2ComfyAPI()
const form = new FormData()
form.append('image', 'data:image/png;base64,abc')
await v1.fetchApi('/upload/image', { method: 'POST', body: form })
const v1Body = (mockFetch.mock.calls[0][1] as RequestInit).body
mockFetch.mockClear()
await v2.fetchApi('/upload/image', { method: 'POST', body: form })
const v2Body = (mockFetch.mock.calls[0][1] as RequestInit).body
expect(v1Body).toBe(v2Body)
})
})
describe('response handling equivalence', () => {
it('both v1 and v2 resolve with a native Response on 200', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('{}', { status: 200 })
)
const v1 = createV1Api()
const v2 = createV2ComfyAPI()
const r1 = await v1.fetchApi('/api/system_stats')
const r2 = await v2.fetchApi('/api/system_stats')
expect(r1).toBeInstanceOf(Response)
expect(r2).toBeInstanceOf(Response)
})
it('both v1 and v2 resolve (not reject) on 4xx/5xx', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('err', { status: 500 })
)
const v1 = createV1Api()
const v2 = createV2ComfyAPI()
const [r1, r2] = await Promise.all([
v1.fetchApi('/api/broken'),
v2.fetchApi('/api/broken')
])
expect(r1.status).toBe(500)
expect(r2.status).toBe(500)
})
})
describe('import-path migration', () => {
it('v2 comfyAPI.fetchApi has the same signature arity as v1 app.api.fetchApi', () => {
const v1 = createV1Api()
const v2 = createV2ComfyAPI()
// Both take (path, init?) → arity 2
expect(v1.fetchApi.length).toBe(2)
expect(v2.fetchApi.length).toBe(2)
})
})
})
// ── Phase B stubs ─────────────────────────────────────────────────────────────
describe('BC.18 migration — backend HTTP calls [Phase B / shell]', () => {
it.todo(
'[shell] v1 app.api.fetchApi and v2 comfyAPI.fetchApi send identical HTTP requests with the same auth headers'
)
it.todo(
'[shell] comfyAPI.fetchApi is available at extension init time without waiting for app.setup()'
)
})

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