test(extension-api-v2): extract shared world mock factories to harness/worldMocks.ts

F3: bc-05.v2, bc-05.migration, bc-11.v2, bc-11.migration each duplicated
the same vi.mock('@/world/...') block (worldInstance + widgetComponents +
entityIds + componentKey + 3 extension-api stubs). Centralise the factory
bodies in src/extension-api-v2/__tests__/harness/worldMocks.ts and have
the four files consume them.

vi.hoisted handles stay inline because the hoisted factory runs before
imports resolve; vi.mock factories wrap the imported helpers in arrows
so the import binding is read lazily.

Verified: 4/4 files pass under vitest run with the shared module.
This commit is contained in:
Connor Byrne
2026-05-11 18:18:55 -07:00
committed by bymyself
parent 9412716b27
commit 1f8fc26019
5 changed files with 278 additions and 122 deletions

View File

@@ -8,35 +8,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ── Mock world (same pattern as bc-01.migration.test.ts) ──────────────────────
const mockGetComponent = vi.fn()
const mockEntitiesWith = vi.fn(() => [])
vi.mock('@/world/worldInstance', () => ({
getWorld: () => ({
getComponent: mockGetComponent,
entitiesWith: mockEntitiesWith,
setComponent: vi.fn(),
removeComponent: vi.fn()
})
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
vi.mock('@/world/widgets/widgetComponents', () => ({
WidgetComponentContainer: Symbol('WidgetComponentContainer'),
WidgetComponentDisplay: Symbol('WidgetComponentDisplay'),
WidgetComponentSchema: Symbol('WidgetComponentSchema'),
WidgetComponentSerialize: Symbol('WidgetComponentSerialize'),
WidgetComponentValue: Symbol('WidgetComponentValue')
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
vi.mock('@/world/entityIds', () => ({}))
// 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/componentKey', () => ({
defineComponentKey: (name: string) => ({ name })
}))
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/extension-api/node', () => ({}))
vi.mock('@/extension-api/widget', () => ({}))
vi.mock('@/extension-api/lifecycle', () => ({}))
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,
@@ -111,7 +110,10 @@ function stubNodeType(id: NodeEntityId, comfyClass = 'TestNode') {
function makeDiv(height = 120): HTMLElement {
const el = document.createElement('div')
Object.defineProperty(el, 'offsetHeight', { value: height, configurable: true })
Object.defineProperty(el, 'offsetHeight', {
value: height,
configurable: true
})
return el
}
@@ -172,14 +174,20 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
// v1: getHeight callback
const v1Node = createV1Node(2)
v1Node.addDOMWidget('widget', 'custom', el, { getHeight: () => reportedHeight })
v1Node.addDOMWidget('widget', 'custom', el, {
getHeight: () => reportedHeight
})
const v1Height = v1Node.domWidgets[0].height
// v2: explicit height option
defineNodeExtension({
name: 'bc05.mig.height-parity',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'widget', element: el, height: reportedHeight })
handle.addDOMWidget({
name: 'widget',
element: el,
height: reportedHeight
})
}
})
const id = makeNodeId(2)
@@ -244,7 +252,10 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
mountExtensionsForNode(id)
const heightCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetOption' && c.key === '__domHeight' && c.value === newHeight
(c) =>
c.type === 'SetWidgetOption' &&
c.key === '__domHeight' &&
c.value === newHeight
)
// v1 needed a computeSize override; v2 achieves the same via SetWidgetOption dispatch

View File

@@ -8,35 +8,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ── Mock world (same pattern as bc-01.v2.test.ts) ────────────────────────────
const mockGetComponent = vi.fn()
const mockEntitiesWith = vi.fn(() => [])
vi.mock('@/world/worldInstance', () => ({
getWorld: () => ({
getComponent: mockGetComponent,
entitiesWith: mockEntitiesWith,
setComponent: vi.fn(),
removeComponent: vi.fn()
})
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
vi.mock('@/world/widgets/widgetComponents', () => ({
WidgetComponentContainer: Symbol('WidgetComponentContainer'),
WidgetComponentDisplay: Symbol('WidgetComponentDisplay'),
WidgetComponentSchema: Symbol('WidgetComponentSchema'),
WidgetComponentSerialize: Symbol('WidgetComponentSerialize'),
WidgetComponentValue: Symbol('WidgetComponentValue')
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
vi.mock('@/world/entityIds', () => ({}))
// 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/componentKey', () => ({
defineComponentKey: (name: string) => ({ name })
}))
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/extension-api/node', () => ({}))
vi.mock('@/extension-api/widget', () => ({}))
vi.mock('@/extension-api/lifecycle', () => ({}))
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,
@@ -63,7 +62,10 @@ function stubNodeType(id: NodeEntityId, comfyClass = 'TestNode') {
function makeDiv(height = 120): HTMLElement {
const el = document.createElement('div')
Object.defineProperty(el, 'offsetHeight', { value: height, configurable: true })
Object.defineProperty(el, 'offsetHeight', {
value: height,
configurable: true
})
return el
}
@@ -123,7 +125,10 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
defineNodeExtension({
name: 'bc05.v2.handle-name',
nodeCreated(handle) {
const wh = handle.addDOMWidget({ name: 'preview', element: makeDiv() })
const wh = handle.addDOMWidget({
name: 'preview',
element: makeDiv()
})
handleName = wh.name
}
})
@@ -163,7 +168,11 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
defineNodeExtension({
name: 'bc05.v2.custom-height',
nodeCreated(handle) {
handle.addDOMWidget({ name: 'editor', element: el, height: customHeight })
handle.addDOMWidget({
name: 'editor',
element: el,
height: customHeight
})
}
})
@@ -227,7 +236,10 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
defineNodeExtension({
name: 'bc05.v2.set-height',
nodeCreated(handle) {
const wh = handle.addDOMWidget({ name: 'resizable', element: makeDiv(100) })
const wh = handle.addDOMWidget({
name: 'resizable',
element: makeDiv(100)
})
wh.setHeight(300)
}
})
@@ -237,7 +249,10 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
mountExtensionsForNode(id)
const setCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetOption' && c.key === '__domHeight' && c.value === 300
(c) =>
c.type === 'SetWidgetOption' &&
c.key === '__domHeight' &&
c.value === 300
)
expect(setCmd).toBeDefined()

View File

@@ -8,35 +8,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ── Mock world (same pattern as bc-01.migration.test.ts) ──────────────────────
const mockGetComponent = vi.fn()
const mockEntitiesWith = vi.fn(() => [])
vi.mock('@/world/worldInstance', () => ({
getWorld: () => ({
getComponent: mockGetComponent,
entitiesWith: mockEntitiesWith,
setComponent: vi.fn(),
removeComponent: vi.fn()
})
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
vi.mock('@/world/widgets/widgetComponents', () => ({
WidgetComponentContainer: Symbol('WidgetComponentContainer'),
WidgetComponentDisplay: Symbol('WidgetComponentDisplay'),
WidgetComponentSchema: Symbol('WidgetComponentSchema'),
WidgetComponentSerialize: Symbol('WidgetComponentSerialize'),
WidgetComponentValue: Symbol('WidgetComponentValue')
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
vi.mock('@/world/entityIds', () => ({}))
// 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/componentKey', () => ({
defineComponentKey: (name: string) => ({ name })
}))
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/extension-api/node', () => ({}))
vi.mock('@/extension-api/widget', () => ({}))
vi.mock('@/extension-api/lifecycle', () => ({}))
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,
@@ -65,7 +64,11 @@ function createV1Widget(name: string, value: unknown): V1Widget {
return { name, value, callback: undefined }
}
function createV1ComboWidget(name: string, value: string, values: string[]): V1Widget {
function createV1ComboWidget(
name: string,
value: string,
values: string[]
): V1Widget {
return { name, value, callback: undefined, options: { values } }
}
@@ -177,7 +180,10 @@ describe('BC.11 migration — widget imperative state writes', () => {
const newValues = ['euler', 'dpm_2', 'lcm']
// v1: direct options mutation
const v1Widget = createV1ComboWidget('sampler', 'euler', ['euler', 'dpm_2'])
const v1Widget = createV1ComboWidget('sampler', 'euler', [
'euler',
'dpm_2'
])
v1Widget.options!.values = newValues
expect(v1Widget.options!.values).toEqual(newValues)
@@ -185,7 +191,9 @@ describe('BC.11 migration — widget imperative state writes', () => {
defineNodeExtension({
name: 'bc11.mig.set-options',
nodeCreated(handle) {
const wh = handle.addWidget('COMBO', 'sampler', 'euler', { values: ['euler', 'dpm_2'] })
const wh = handle.addWidget('COMBO', 'sampler', 'euler', {
values: ['euler', 'dpm_2']
})
wh.setOption('values', newValues)
}
})
@@ -203,8 +211,14 @@ describe('BC.11 migration — widget imperative state writes', () => {
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'])
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'])
@@ -214,8 +228,12 @@ describe('BC.11 migration — widget imperative state writes', () => {
defineNodeExtension({
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'] })
const whA = handle.addWidget('COMBO', 'schedulerA', 'karras', {
values: ['karras', 'normal']
})
handle.addWidget('COMBO', 'schedulerB', 'karras', {
values: ['karras', 'normal']
})
whA.setOption('values', ['karras', 'exponential'])
}
})
@@ -223,7 +241,9 @@ describe('BC.11 migration — widget imperative state writes', () => {
stubNodeType(id)
mountExtensionsForNode(id)
const optCmds = dispatchedCommands.filter((c) => c.type === 'SetWidgetOption' && c.key === 'values')
const optCmds = dispatchedCommands.filter(
(c) => c.type === 'SetWidgetOption' && c.key === 'values'
)
// Only one setOption dispatch — for whA
expect(optCmds).toHaveLength(1)
})

View File

@@ -8,35 +8,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ── Mock world (same pattern as bc-01.v2.test.ts) ────────────────────────────
const mockGetComponent = vi.fn()
const mockEntitiesWith = vi.fn(() => [])
vi.mock('@/world/worldInstance', () => ({
getWorld: () => ({
getComponent: mockGetComponent,
entitiesWith: mockEntitiesWith,
setComponent: vi.fn(),
removeComponent: vi.fn()
})
// vi.hoisted factory runs before imports — keep handle creation inline.
const { mockGetComponent, mockEntitiesWith } = vi.hoisted(() => ({
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [] as unknown[])
}))
vi.mock('@/world/widgets/widgetComponents', () => ({
WidgetComponentContainer: Symbol('WidgetComponentContainer'),
WidgetComponentDisplay: Symbol('WidgetComponentDisplay'),
WidgetComponentSchema: Symbol('WidgetComponentSchema'),
WidgetComponentSerialize: Symbol('WidgetComponentSerialize'),
WidgetComponentValue: Symbol('WidgetComponentValue')
}))
import {
componentKeyMockFactory,
emptyMockFactory,
widgetComponentsMockFactory,
worldInstanceMockFactory
} from './harness/worldMocks'
vi.mock('@/world/entityIds', () => ({}))
// 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/componentKey', () => ({
defineComponentKey: (name: string) => ({ name })
}))
vi.mock('@/world/widgets/widgetComponents', () => widgetComponentsMockFactory())
vi.mock('@/extension-api/node', () => ({}))
vi.mock('@/extension-api/widget', () => ({}))
vi.mock('@/extension-api/lifecycle', () => ({}))
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,
@@ -127,9 +126,9 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
stubNodeType(id)
mountExtensionsForNode(id)
const setCmd = dispatchedCommands.find((c) => c.type === 'SetWidgetValue') as
| { widgetId: string; value: unknown }
| undefined
const setCmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetValue'
) as { widgetId: string; value: unknown } | undefined
expect(setCmd).toBeDefined()
expect(setCmd?.widgetId).toBe(capturedWidgetId[0])
@@ -151,7 +150,9 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
stubNodeType(id)
mountExtensionsForNode(id)
const setCmds = dispatchedCommands.filter((c) => c.type === 'SetWidgetValue')
const setCmds = dispatchedCommands.filter(
(c) => c.type === 'SetWidgetValue'
)
expect(setCmds).toHaveLength(3)
expect(setCmds.map((c) => c.value)).toEqual([1, 2, 3])
})
@@ -172,7 +173,8 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
mountExtensionsForNode(id)
const cmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetOption' && c.key === 'hidden' && c.value === true
(c) =>
c.type === 'SetWidgetOption' && c.key === 'hidden' && c.value === true
)
expect(cmd).toBeDefined()
})
@@ -191,7 +193,10 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
mountExtensionsForNode(id)
const cmd = dispatchedCommands.find(
(c) => c.type === 'SetWidgetOption' && c.key === 'disabled' && c.value === true
(c) =>
c.type === 'SetWidgetOption' &&
c.key === 'disabled' &&
c.value === true
)
expect(cmd).toBeDefined()
})
@@ -202,7 +207,9 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
defineNodeExtension({
name: 'bc11.v2.set-option',
nodeCreated(handle) {
const wh = handle.addWidget('COMBO', 'sampler_name', 'euler', { values: ['euler', 'dpm_2'] })
const wh = handle.addWidget('COMBO', 'sampler_name', 'euler', {
values: ['euler', 'dpm_2']
})
wh.setOption('values', ['euler', 'dpm_2', 'lcm'])
}
})
@@ -233,7 +240,9 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
stubNodeType(id)
mountExtensionsForNode(id)
const optCmds = dispatchedCommands.filter((c) => c.type === 'SetWidgetOption')
const optCmds = dispatchedCommands.filter(
(c) => c.type === 'SetWidgetOption'
)
const keys = optCmds.map((c) => c.key)
expect(keys).toContain('placeholder')
expect(keys).toContain('maxLength')
@@ -276,7 +285,9 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
stubNodeType(id)
mountExtensionsForNode(id)
const createCmds = dispatchedCommands.filter((c) => c.type === 'CreateWidget')
const createCmds = dispatchedCommands.filter(
(c) => c.type === 'CreateWidget'
)
const names = createCmds.map((c) => c.name)
expect(names).toContain('steps')
expect(names).toContain('cfg')

View File

@@ -0,0 +1,99 @@
/**
* Shared `vi.mock(...)` payloads for tests that exercise
* `@/services/extension-api-service` against a stubbed World.
*
* Why this exists: BC.05 / BC.11 (and any future ECS-touching BC tests)
* had identical, copy-pasted blocks of:
*
* const mockGetComponent = vi.fn()
* const mockEntitiesWith = vi.fn(() => [])
* vi.mock('@/world/worldInstance', ...)
* vi.mock('@/world/widgets/widgetComponents', ...)
* vi.mock('@/world/entityIds', () => ({}))
* vi.mock('@/world/componentKey', ...)
* vi.mock('@/extension-api/node', () => ({}))
* vi.mock('@/extension-api/widget', () => ({}))
* vi.mock('@/extension-api/lifecycle', () => ({}))
*
* `vi.mock` is statically hoisted, so the *call sites* must remain in
* the consumer file. What we centralise here is the factory *bodies*
* plus a handle-creation helper that pairs cleanly with `vi.hoisted`.
*
* @example
* import { vi } from 'vitest'
* import {
* createWorldMockHandles,
* emptyMockFactory,
* componentKeyMockFactory,
* widgetComponentsMockFactory,
* worldInstanceMockFactory
* } from './harness/worldMocks'
*
* const { mockGetComponent, mockEntitiesWith } = vi.hoisted(
* createWorldMockHandles
* )
*
* 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 { vi } from 'vitest'
export interface WorldMockHandles {
mockGetComponent: ReturnType<typeof vi.fn>
mockEntitiesWith: ReturnType<typeof vi.fn>
}
/**
* Hoist-safe factory for the per-test mock function handles.
* Wrap with `vi.hoisted(createWorldMockHandles)` at the top of the
* test file so the resulting handles are available inside the
* `vi.mock(...)` factory closures.
*/
export function createWorldMockHandles(): WorldMockHandles {
return {
mockGetComponent: vi.fn(),
mockEntitiesWith: vi.fn(() => [])
}
}
/** Factory body for `@/world/worldInstance`. */
export function worldInstanceMockFactory(handles: WorldMockHandles) {
return {
getWorld: () => ({
getComponent: handles.mockGetComponent,
entitiesWith: handles.mockEntitiesWith,
setComponent: vi.fn(),
removeComponent: vi.fn()
})
}
}
/** Factory body for `@/world/widgets/widgetComponents`. */
export function widgetComponentsMockFactory() {
return {
WidgetComponentContainer: Symbol('WidgetComponentContainer'),
WidgetComponentDisplay: Symbol('WidgetComponentDisplay'),
WidgetComponentSchema: Symbol('WidgetComponentSchema'),
WidgetComponentSerialize: Symbol('WidgetComponentSerialize'),
WidgetComponentValue: Symbol('WidgetComponentValue')
}
}
/** Factory body for `@/world/componentKey`. */
export function componentKeyMockFactory() {
return {
defineComponentKey: (name: string) => ({ name })
}
}
/** Factory body for modules that need to be mocked but contribute nothing. */
export function emptyMockFactory() {
return {}
}