fix(ext-api/tf): relax knip + format pass for restacked tf

- knip.config.ts: drop `treatConfigHintsAsErrors` to false in tf only —
  tf adds test consumers of foundation's @publicAPI-tagged symbols
  (`_setDispatchImplForTesting`, `NodeExtensionOptions`, etc.) which
  makes those tags 'redundant' from knip's POV. The tags are still
  correct on foundation alone, so we keep the tag definition and just
  downgrade hint→warning here.
- knip.config.ts: extend vitest config glob to include .mts (project is
  "type": "module" so vitest configs use the .mts extension).
- bc-08/28/29/30 test files + ADR 0010: oxfmt formatting fixes after
  rebase onto restacked ext.

Cascade follow-up to STACK-HYGIENE restratification. --no-verify used
because pre-commit runs full typecheck on staged TS files and tf has 61
pre-existing typecheck errors (same count with or without this commit);
per AGENTS.md rule #8, only lint/format/knip must pass during Phase A
on the ext-api stack.
This commit is contained in:
Connor Byrne
2026-05-13 13:07:04 -07:00
committed by bymyself
parent 5638744ea7
commit 89080d0a1e
11 changed files with 577 additions and 120 deletions

View File

@@ -36,7 +36,7 @@ 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)
- Presence of constraints (the _fact_ that min/max/step exist)
- Default values
Schema comes from the node definition and is frozen at construction time.
@@ -73,8 +73,8 @@ interface WidgetHandle<T> {
// Props: value (modelValue) — ergonomic accessor
value: T
getValue(): T // alias
setValue(v: T): void // alias
getValue(): T // alias
setValue(v: T): void // alias
// Props: common — ergonomic accessors
isHidden(): boolean
@@ -92,13 +92,13 @@ interface WidgetHandle<T> {
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` |
| 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.

View File

@@ -1,7 +1,12 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
treatConfigHintsAsErrors: true,
// I-TF (#12145): the test framework references symbols that foundation
// tags with @publicAPI (e.g. `_setDispatchImplForTesting`,
// `NodeExtensionOptions`). With tests present those tags become
// "redundant" hints. They are still correct on foundation alone, so
// we keep the tag definition and just downgrade hint→warning here.
treatConfigHintsAsErrors: false,
workspaces: {
'.': {
entry: [
@@ -102,7 +107,9 @@ const config: KnipConfig = {
config: ['vite?(.*).config.mts']
},
vitest: {
config: ['vitest?(.*).config.ts'],
// I-TF (#12145) adds vitest.extension-api.config.mts; project uses
// "type": "module" so vitest configs use the .mts extension.
config: ['vitest?(.*).config.ts', 'vitest?(.*).config.mts'],
entry: [
'**/*.{bench,test,test-d,spec}.?(c|m)[jt]s?(x)',
'**/__mocks__/**/*.[jt]s?(x)'

View File

@@ -97,9 +97,16 @@ interface LinkHandleV2 {
interface NodeHandleV2 {
readonly entityId: string
readonly type: string
connect(srcSlot: number, targetHandle: NodeHandleV2, dstSlot: number): LinkHandleV2 | null
connect(
srcSlot: number,
targetHandle: NodeHandleV2,
dstSlot: number
): LinkHandleV2 | null
disconnectInput(slotIndex: number): void
on(event: 'connectionChange', handler: (e: ConnectionChangeEventV2) => void): () => void
on(
event: 'connectionChange',
handler: (e: ConnectionChangeEventV2) => void
): () => void
}
// ── V1 Synthetic Implementations ─────────────────────────────────────────────
@@ -192,10 +199,22 @@ function createMockNodeV1(
const link = graph._createLink(node, srcSlot, targetNode, dstSlot)
if (link) {
if (node.onConnectionsChange) {
node.onConnectionsChange(2, srcSlot, true, link, node.outputs[srcSlot])
node.onConnectionsChange(
2,
srcSlot,
true,
link,
node.outputs[srcSlot]
)
}
if (targetNode.onConnectionsChange) {
targetNode.onConnectionsChange(1, dstSlot, true, link, targetNode.inputs[dstSlot])
targetNode.onConnectionsChange(
1,
dstSlot,
true,
link,
targetNode.inputs[dstSlot]
)
}
}
return link
@@ -208,7 +227,9 @@ function createMockNodeV1(
const link = graph.links.get(slotObj.link)
if (!link) return
const srcNode = graph.getNodeById(link.origin_id) as MockNodeV1WithMethods | undefined
const srcNode = graph.getNodeById(link.origin_id) as
| MockNodeV1WithMethods
| undefined
graph._removeLink(slotObj.link)
@@ -216,7 +237,13 @@ function createMockNodeV1(
node.onConnectionsChange(1, slot, false, null, slotObj)
}
if (srcNode?.onConnectionsChange) {
srcNode.onConnectionsChange(2, link.origin_slot, false, null, srcNode.outputs[link.origin_slot])
srcNode.onConnectionsChange(
2,
link.origin_slot,
false,
null,
srcNode.outputs[link.origin_slot]
)
}
}
}
@@ -338,13 +365,19 @@ function createNodeHandleV2(
if (srcNode) {
srcNode.connectionListeners.forEach((fn) =>
fn({ side: 'output', slotIndex: link.origin_slot, connected: false, linkId: null })
fn({
side: 'output',
slotIndex: link.origin_slot,
connected: false,
linkId: null
})
)
}
},
on(event, handler) {
if (event !== 'connectionChange') throw new Error(`Unknown event: ${event}`)
if (event !== 'connectionChange')
throw new Error(`Unknown event: ${event}`)
internal.connectionListeners.push(handler)
return () => {
const idx = internal.connectionListeners.indexOf(handler)
@@ -363,16 +396,38 @@ describe('BC.08 migration — programmatic linking', () => {
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' }], [])
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 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
@@ -393,15 +448,37 @@ describe('BC.08 migration — programmatic linking', () => {
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' }], [])
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 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
@@ -412,8 +489,18 @@ describe('BC.08 migration — programmatic linking', () => {
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' }], [])
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)
@@ -421,8 +508,20 @@ describe('BC.08 migration — programmatic linking', () => {
// 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' }], [])
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
@@ -435,8 +534,18 @@ describe('BC.08 migration — programmatic linking', () => {
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' }], [])
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)
@@ -447,8 +556,20 @@ describe('BC.08 migration — programmatic linking', () => {
// 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 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)
@@ -457,10 +578,21 @@ describe('BC.08 migration — programmatic linking', () => {
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 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' }], [])
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)
@@ -470,13 +602,33 @@ describe('BC.08 migration — programmatic linking', () => {
dstV1.disconnectInput(0, graphV1)
// V2
const v2Calls: Array<{ side: string; slotIndex: number; connected: boolean }> = []
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' }], [])
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 })
v2Calls.push({
side: e.side,
slotIndex: e.slotIndex,
connected: e.connected
})
})
dstV2.disconnectInput(0)
@@ -503,8 +655,20 @@ describe('BC.08 migration — programmatic linking', () => {
// 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' }], [])
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)
@@ -520,11 +684,22 @@ describe('BC.08 migration — programmatic linking', () => {
// 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' }])
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' }])
const handleV2 = createNodeHandleV2(
worldV2,
'node-42',
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
// Both represent a KSampler with one LATENT output
expect(nodeV1.type).toBe('KSampler')

View File

@@ -77,7 +77,11 @@ function createMockGraph(): MockGraph {
if (!srcSlotObj || !dstSlotObj) return null
// Type compatibility check (simplified)
if (srcSlotObj.type !== dstSlotObj.type && srcSlotObj.type !== '*' && dstSlotObj.type !== '*') {
if (
srcSlotObj.type !== dstSlotObj.type &&
srcSlotObj.type !== '*' &&
dstSlotObj.type !== '*'
) {
return null
}
@@ -119,7 +123,12 @@ function createMockGraph(): MockGraph {
}
interface MockNodeWithMethods extends MockNode {
connect: (srcSlot: number, targetNode: MockNodeWithMethods, dstSlot: number, graph: MockGraph) => MockLink | null
connect: (
srcSlot: number,
targetNode: MockNodeWithMethods,
dstSlot: number,
graph: MockGraph
) => MockLink | null
disconnectInput: (slot: number, graph: MockGraph) => void
}
@@ -136,17 +145,34 @@ function createMockNode(
outputs: outputs.map((o) => ({ ...o, link: null })),
onConnectionsChange: undefined,
connect(srcSlot: number, targetNode: MockNodeWithMethods, dstSlot: number, graph: MockGraph) {
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])
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])
targetNode.onConnectionsChange(
1,
dstSlot,
true,
link,
targetNode.inputs[dstSlot]
)
}
}
@@ -160,7 +186,9 @@ function createMockNode(
const link = graph.links.get(slotObj.link)
if (!link) return
const srcNode = graph.getNodeById(link.origin_id) as MockNodeWithMethods | undefined
const srcNode = graph.getNodeById(link.origin_id) as
| MockNodeWithMethods
| undefined
graph._removeLink(slotObj.link)
@@ -170,7 +198,13 @@ function createMockNode(
}
// Fire onConnectionsChange on source node (output side)
if (srcNode?.onConnectionsChange) {
srcNode.onConnectionsChange(2, link.origin_slot, false, null, srcNode.outputs[link.origin_slot])
srcNode.onConnectionsChange(
2,
link.origin_slot,
false,
null,
srcNode.outputs[link.origin_slot]
)
}
}
}
@@ -184,8 +218,18 @@ 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' }], [])
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
@@ -201,8 +245,18 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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' }], [])
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
@@ -213,7 +267,12 @@ describe('BC.08 v1 contract — programmatic linking', () => {
expect(link1!.id).toBeGreaterThan(0)
// Second link gets next ID
const dstNode2 = createMockNode(3, 'VAEDecode', [{ name: 'samples', type: 'LATENT' }], [])
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)
@@ -221,9 +280,24 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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' }], [])
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)
@@ -245,8 +319,18 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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' }], [])
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'SaveImage',
[{ name: 'images', type: 'IMAGE' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
@@ -262,11 +346,23 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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 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' }], [])
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) => {
@@ -291,8 +387,18 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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' }], [])
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
@@ -309,7 +415,12 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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' }], [])
const dstNode = createMockNode(
1,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(dstNode)
@@ -320,11 +431,23 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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 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' }], [])
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
@@ -352,8 +475,18 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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' }], [])
const srcNode = createMockNode(
1,
'Reroute',
[],
[{ name: 'output', type: '*' }]
)
const dstNode = createMockNode(
2,
'VAEDecode',
[{ name: 'samples', type: 'LATENT' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)
@@ -364,8 +497,18 @@ describe('BC.08 v1 contract — programmatic linking', () => {
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: '*' }], [])
const srcNode = createMockNode(
1,
'KSampler',
[],
[{ name: 'LATENT', type: 'LATENT' }]
)
const dstNode = createMockNode(
2,
'Reroute',
[{ name: 'input', type: '*' }],
[]
)
graph.add(srcNode)
graph.add(dstNode)

View File

@@ -61,7 +61,10 @@ interface NodeHandle {
dstSlot: number
): LinkHandle | null
disconnectInput(slotIndex: number): void
on(event: 'connectionChange', handler: (e: ConnectionChangeEvent) => void): () => void
on(
event: 'connectionChange',
handler: (e: ConnectionChangeEvent) => void
): () => void
}
// ── Synthetic implementations ────────────────────────────────────────────────
@@ -105,7 +108,11 @@ function createNodeHandle(
return internal.type
},
connect(srcSlot: number, targetHandle: NodeHandle, dstSlot: number): LinkHandle | null {
connect(
srcSlot: number,
targetHandle: NodeHandle,
dstSlot: number
): LinkHandle | null {
const srcSlotObj = internal.outputs[srcSlot]
const targetInternal = world.nodes.get(targetHandle.entityId)
if (!targetInternal) return null
@@ -130,10 +137,20 @@ function createNodeHandle(
world.links.delete(dstSlotObj.link)
// Fire connectionChange for disconnect
internal.connectionListeners.forEach((fn) =>
fn({ side: 'output', slotIndex: srcSlot, connected: false, linkId: null })
fn({
side: 'output',
slotIndex: srcSlot,
connected: false,
linkId: null
})
)
targetInternal.connectionListeners.forEach((fn) =>
fn({ side: 'input', slotIndex: dstSlot, connected: false, linkId: null })
fn({
side: 'input',
slotIndex: dstSlot,
connected: false,
linkId: null
})
)
}
dstSlotObj.link = null
@@ -193,13 +210,22 @@ function createNodeHandle(
// Fire connectionChange on source
if (srcNode) {
srcNode.connectionListeners.forEach((fn) =>
fn({ side: 'output', slotIndex: link.origin_slot, connected: false, linkId: null })
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}`)
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)
@@ -335,7 +361,9 @@ describe('BC.08 v2 contract — programmatic linking', () => {
)
const initialLinkCount = world.links.size
expect(() => srcHandle.connect(0, dstHandle, 0)).toThrow(TypeMismatchError)
expect(() => srcHandle.connect(0, dstHandle, 0)).toThrow(
TypeMismatchError
)
expect(world.links.size).toBe(initialLinkCount)
})

View File

@@ -102,7 +102,10 @@ describe('BC.28 migration — subgraph fan-out via set/get virtual nodes', () =>
target_id: number
}
function v1GraphToPromptPatch(link: V1Link, resolvedOriginId: number): void {
function v1GraphToPromptPatch(
link: V1Link,
resolvedOriginId: number
): void {
link.origin_id = resolvedOriginId // Mutation!
}
@@ -157,8 +160,12 @@ describe('BC.28 migration — subgraph fan-out via set/get virtual nodes', () =>
expect(typeof receivedGraph!.findByType).toBe('function')
expect(typeof receivedGraph!.getNode).toBe('function')
// No add, remove, or link mutation methods
expect((receivedGraph as unknown as Record<string, unknown>).addNode).toBeUndefined()
expect((receivedGraph as unknown as Record<string, unknown>).removeNode).toBeUndefined()
expect(
(receivedGraph as unknown as Record<string, unknown>).addNode
).toBeUndefined()
expect(
(receivedGraph as unknown as Record<string, unknown>).removeNode
).toBeUndefined()
})
it.todo(
@@ -171,7 +178,10 @@ describe('BC.28 migration — subgraph fan-out via set/get virtual nodes', () =>
// This is documented but not type-checked here (Phase B implementation)
interface AppExtensionContext {
on(event: 'beforePrompt', handler: (event: { spec: unknown }) => void): void
on(
event: 'beforePrompt',
handler: (event: { spec: unknown }) => void
): void
}
// Type-level proof that the bridge API shape exists
@@ -224,7 +234,14 @@ describe('BC.28 migration — subgraph fan-out via set/get virtual nodes', () =>
const mockGraph: ReadOnlyGraph = {
findByType: (type) =>
type === 'SetNode'
? [{ entityId: 'node:set', type: 'SetNode', title: 'myValue', getProperty: () => undefined }]
? [
{
entityId: 'node:set',
type: 'SetNode',
title: 'myValue',
getProperty: () => undefined
}
]
: [],
getNode: () => undefined
}

View File

@@ -162,8 +162,20 @@ describe('BC.28 v1 contract — subgraph fan-out via set/get virtual nodes', ()
graph._nodes = [upstreamNode, setNode, getNode, downstreamNode]
graph.links = {
100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 },
101: { id: 101, origin_id: 3, origin_slot: 0, target_id: 4, target_slot: 0 }
100: {
id: 100,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0
},
101: {
id: 101,
origin_id: 3,
origin_slot: 0,
target_id: 4,
target_slot: 0
}
}
// graphToPrompt rewriting logic: replace Get→downstream with Set-source→downstream
@@ -218,24 +230,69 @@ describe('BC.28 v1 contract — subgraph fan-out via set/get virtual nodes', ()
it('multiple Get nodes referencing the same Set name all resolve to the same upstream', () => {
const graph = createV1Graph()
const upstream: V1Node = { id: 1, type: 'KSampler', title: 'S', outputs: [{ links: [100] }] }
const upstream: V1Node = {
id: 1,
type: 'KSampler',
title: 'S',
outputs: [{ links: [100] }]
}
const setNode: V1Node = {
id: 2, type: 'SetNode', title: 'shared', inputs: [{ link: 100 }], outputs: []
id: 2,
type: 'SetNode',
title: 'shared',
inputs: [{ link: 100 }],
outputs: []
}
const get1: V1Node = {
id: 3, type: 'GetNode', title: 'shared', inputs: [], outputs: [{ links: [101] }]
id: 3,
type: 'GetNode',
title: 'shared',
inputs: [],
outputs: [{ links: [101] }]
}
const get2: V1Node = {
id: 4, type: 'GetNode', title: 'shared', inputs: [], outputs: [{ links: [102] }]
id: 4,
type: 'GetNode',
title: 'shared',
inputs: [],
outputs: [{ links: [102] }]
}
const down1: V1Node = {
id: 5,
type: 'VAEDecode',
title: 'D1',
inputs: [{ link: 101 }]
}
const down2: V1Node = {
id: 6,
type: 'VAEDecode',
title: 'D2',
inputs: [{ link: 102 }]
}
const down1: V1Node = { id: 5, type: 'VAEDecode', title: 'D1', inputs: [{ link: 101 }] }
const down2: V1Node = { id: 6, type: 'VAEDecode', title: 'D2', inputs: [{ link: 102 }] }
graph._nodes = [upstream, setNode, get1, get2, down1, down2]
graph.links = {
100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 },
101: { id: 101, origin_id: 3, origin_slot: 0, target_id: 5, target_slot: 0 },
102: { id: 102, origin_id: 4, origin_slot: 0, target_id: 6, target_slot: 0 }
100: {
id: 100,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0
},
101: {
id: 101,
origin_id: 3,
origin_slot: 0,
target_id: 5,
target_slot: 0
},
102: {
id: 102,
origin_id: 4,
origin_slot: 0,
target_id: 6,
target_slot: 0
}
}
// Both Get nodes should resolve to node 1 (upstream)

View File

@@ -152,7 +152,10 @@ describe('BC.29 migration — graph enumeration, mutation, and cross-scope ident
})
it('createNodeLocatorId is the same function in both v1 and v2 (re-exported)', () => {
const locator = createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
const locator = createNodeLocatorId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
456
)
expect(locator).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
expect(isNodeLocatorId(locator)).toBe(true)
@@ -160,7 +163,9 @@ describe('BC.29 migration — graph enumeration, mutation, and cross-scope ident
it('v2 adds isNodeLocatorId type guard not present in v1 (enhancement)', () => {
// v2 enhancement: type guard for runtime validation
expect(isNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890:123')).toBe(true)
expect(isNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890:123')).toBe(
true
)
expect(isNodeLocatorId('invalid')).toBe(true) // simple string is valid root node ID
expect(isNodeLocatorId('not-a-uuid:123')).toBe(false) // invalid uuid format
expect(isNodeLocatorId(null)).toBe(false)
@@ -171,7 +176,10 @@ describe('BC.29 migration — graph enumeration, mutation, and cross-scope ident
const v1Style = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ':' + '789'
// v2 pattern: use createNodeLocatorId
const v2Style = createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 789)
const v2Style = createNodeLocatorId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
789
)
// Both produce the same result
expect(v1Style).toBe(v2Style)

View File

@@ -112,7 +112,9 @@ describe('BC.29 v1 contract — graph enumeration, mutation, and cross-scope ide
describe('S14.ID1 — cross-subgraph identity helpers', () => {
it('parseNodeLocatorId(id) splits a locator string into { subgraphUuid, localNodeId } parts', () => {
const result = parseNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890:123')
const result = parseNodeLocatorId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:123'
)
expect(result).not.toBeNull()
expect(result!.subgraphUuid).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
@@ -120,7 +122,10 @@ describe('BC.29 v1 contract — graph enumeration, mutation, and cross-scope ide
})
it('createNodeLocatorId(scope, localId) produces a stable colon-delimited locator string', () => {
const locator = createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
const locator = createNodeLocatorId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
456
)
expect(locator).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
})

View File

@@ -59,12 +59,17 @@ describe('BC.29 v2 contract — graph enumeration, mutation, and cross-scope ide
const found = graph.findByType('KSampler')
expect(found).toHaveLength(2)
expect(found.every((n: unknown) => (n as { type: string }).type === 'KSampler')).toBe(true)
expect(
found.every((n: unknown) => (n as { type: string }).type === 'KSampler')
).toBe(true)
})
it('comfyApp.graph.addNode(opts) creates and inserts a new node', () => {
const graph = createMockGraphHandle()
const node = graph.addNode({ type: 'TestNode' }) as { type: string; id: number }
const node = graph.addNode({ type: 'TestNode' }) as {
type: string
id: number
}
expect(node.type).toBe('TestNode')
expect(node.id).toBeDefined()
@@ -102,7 +107,9 @@ describe('BC.29 v2 contract — graph enumeration, mutation, and cross-scope ide
})
it('isNodeLocatorId returns true for valid subgraph node IDs (uuid:localId)', () => {
expect(isNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890:123')).toBe(true)
expect(
isNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890:123')
).toBe(true)
})
it('isNodeLocatorId returns false for invalid formats', () => {
@@ -113,10 +120,14 @@ describe('BC.29 v2 contract — graph enumeration, mutation, and cross-scope ide
})
it('parseNodeLocatorId extracts subgraphUuid and localNodeId for subgraph nodes', () => {
const result = parseNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
const result = parseNodeLocatorId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).not.toBeNull()
expect(result!.subgraphUuid).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
expect(result!.subgraphUuid).toBe(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
)
expect(result!.localNodeId).toBe(456)
})
@@ -135,7 +146,10 @@ describe('BC.29 v2 contract — graph enumeration, mutation, and cross-scope ide
describe('NodeLocatorId creation', () => {
it('createNodeLocatorId produces a colon-delimited string', () => {
const id = createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 123)
const id = createNodeLocatorId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
123
)
expect(id).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:123')
expect(isNodeLocatorId(id)).toBe(true)
@@ -208,11 +222,14 @@ describe('BC.29 v2 contract — graph enumeration, mutation, and cross-scope ide
it('NodeLocatorId is stable across all instances of a subgraph', () => {
// A locator ID like "uuid:123" identifies the same node definition
// regardless of which execution path reaches it
const locatorId: NodeLocatorId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:123'
const locatorId: NodeLocatorId =
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:123'
expect(isNodeLocatorId(locatorId)).toBe(true)
const parsed = parseNodeLocatorId(locatorId)
expect(parsed!.subgraphUuid).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
expect(parsed!.subgraphUuid).toBe(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
)
})
})
})

View File

@@ -98,7 +98,9 @@ describe('BC.30 v2 contract — graph change tracking, batching, and reactivity
})
it('graph._version does not exist on the v2 GraphHandle', () => {
const graph = createReactiveGraphHandle() as unknown as { _version?: number }
const graph = createReactiveGraphHandle() as unknown as {
_version?: number
}
expect(graph._version).toBeUndefined()
})
@@ -232,8 +234,6 @@ describe('BC.30 v2 contract — graph change tracking, batching, and reactivity
warnSpy.mockRestore()
})
it.todo(
'[Phase B] setDirtyCanvas shim is wired into NodeHandle at runtime'
)
it.todo('[Phase B] setDirtyCanvas shim is wired into NodeHandle at runtime')
})
})