Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(extension-api): test framework reorg + harness + content fill (I…
…-TF.2/3/6)

- Layout reorg: nested __tests__/{v1,v2,migration}/BC.XX/ → flat
  __tests__/bc-XX.{v1,v2,migration}.test.ts (124 deletions, 121 fills)
- src/extension-api-v2/harness/: synthetic mini-ComfyApp + World stub
  with loadEvidenceSnippet() pulling R8 clone-and-grep excerpts
- vitest.extension-api.config.mts: adjusted for flat layout
- BC coverage: 41 categories × 3 stub types (v1/v2/migration)

Stacks on ext-api/i-foundation. Coworkers converting core extensions
should also branch off i-foundation, parallel to this PR.
  • Loading branch information
Connor Byrne committed May 9, 2026
commit aa678557976279cc23cd4e224d65f5bab7bdef69
196 changes: 173 additions & 23 deletions src/extension-api-v2/__tests__/bc-01.migration.test.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,189 @@
// Category: BC.01 — Node lifecycle: creation
// DB cross-ref: S2.N1, S2.N8
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 nodeCreated(node) + beforeRegisterNodeDef → v2 defineNodeExtension({ nodeCreated(handle) })
//
// 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, it } from 'vitest'
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'

// ── V1 app shim ───────────────────────────────────────────────────────────────
// Minimal stand-in for v1 app.registerExtension behavior.

interface V1NodeLike { id: number; type: string }
interface V1Extension {
name: string
nodeCreated?: (node: V1NodeLike) => void
}

function createV1App() {
const extensions: V1Extension[] = []
const callLog: V1NodeLike[] = []

return {
registerExtension(ext: V1Extension) { extensions.push(ext) },
simulateNodeCreated(node: V1NodeLike) {
callLog.push(node)
for (const ext of extensions) ext.nodeCreated?.(node)
},
get totalCreated() { return callLog.length }
}
}

// ── V2 stub runtime ───────────────────────────────────────────────────────────
// Mirrors the real service contract without the ECS dependency.

interface NodeRecord { entityId: NodeEntityId; comfyClass: string }

function createV2Runtime() {
const extensions: NodeExtensionOptions[] = []
const nodes = new Map<NodeEntityId, NodeRecord>()
let nextId = 1

function makeId(): NodeEntityId {
return `node:mig-test:${nextId++}` as NodeEntityId
}

function createHandle(r: NodeRecord): NodeHandle {
return {
entityId: r.entityId,
get type() { return r.comfyClass },
get comfyClass() { return r.comfyClass },
getPosition: () => [0, 0],
getSize: () => [0, 0],
getTitle: () => r.comfyClass,
setTitle: () => {},
getMode: () => 0,
setMode: () => {},
getProperty: () => undefined,
getProperties: () => ({}),
setProperty: () => {},
widget: () => undefined,
widgets: () => [],
addWidget: () => { throw new Error('not implemented') },
inputs: () => [],
outputs: () => [],
on: () => () => {},
} as unknown as NodeHandle
}

function register(options: NodeExtensionOptions) { extensions.push(options) }

function mountNode(comfyClass: string, isLoaded = false): NodeEntityId {
const id = makeId()
nodes.set(id, { entityId: id, comfyClass })
const sorted = [...extensions].sort((a, b) => a.name.localeCompare(b.name))
for (const ext of sorted) {
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
const hook = isLoaded ? ext.loadedGraphNode : ext.nodeCreated
hook?.(createHandle({ entityId: id, comfyClass }))
}
return id
}

function clear() { extensions.length = 0; nodes.clear(); nextId = 1 }

return { register, mountNode, clear }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('BC.01 migration — node lifecycle: creation', () => {
describe('nodeCreated parity (S2.N1)', () => {
it.todo(
'v1 nodeCreated and v2 nodeCreated are both invoked the same number of times when N nodes are created'
)
it.todo(
'side-effects applied to the node in v1 nodeCreated(node) are reproducible via NodeHandle methods in v2'
)
it.todo(
'v2 nodeCreated fires in the same relative order as v1 for extensions registered in the same order'
)
describe('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-scoped defineNodeExtension (S2.N8)', () => {
it.todo(
'prototype mutation applied in v1 beforeRegisterNodeDef produces the same per-instance behavior as v2 type-scoped nodeCreated'
)
it.todo(
'v2 type-scoped extension does not affect node types that were excluded, matching v1 type-guard behavior'
)
describe('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(
'both v1 and v2 nodeCreated fire before VueNode mounts — extensions relying on this ordering do not need changes'
)
it.todo(
'extensions that deferred DOM work to a callback in v1 can use onNodeMounted in v2 for the same guarantee'
// Phase B: requires two-phase harness simulation (BC.37).
'both v1 and v2 nodeCreated fire before VueNode mounts — runtime proof deferred to Phase B'
)
})
})
147 changes: 121 additions & 26 deletions src/extension-api-v2/__tests__/bc-01.v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,134 @@
// Note: nodeCreated fires BEFORE the VueNode Vue component mounts; extensions needing
// VueNode-backed state must defer (see BC.37).

import { describe, it } from 'vitest'
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 — nodeCreated hook', () => {
it.todo(
'nodeCreated is called once per node instance immediately after the node is constructed'
)
it.todo(
'nodeCreated receives the LGraphNode instance as its first argument'
)
it.todo(
'nodeCreated fires before the node is added to the graph (graph.nodes does not yet contain the node)'
)
it.todo(
'nodeCreated fires before the VueNode Vue component is mounted (vm.$el is null at call time)'
)
it.todo(
'properties set on node inside nodeCreated are accessible in subsequent lifecycle hooks'
)
describe('S2.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 — beforeRegisterNodeDef hook', () => {
it.todo(
'beforeRegisterNodeDef is called once per node type before the type is registered in the node registry'
)
it.todo(
'beforeRegisterNodeDef receives the node constructor and the raw node definition object'
)
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(() => callOrder.push('A')) }
const extB = { nodeCreated: vi.fn(() => 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(
'prototype mutations made in beforeRegisterNodeDef affect all subsequently created instances of that type'
'fires before node is added to graph'
)

it.todo(
'beforeRegisterNodeDef is NOT called again on graph reload if the type is already registered'
'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')
})
})
})
Loading
Loading