test: add nodeOutputStore coverage for snapshot/restore, reset, merge, and tab switching (#9618)

## Summary

Adds comprehensive test coverage for the nodeOutputStore, which has been
patched 8+ times in 3 months for reactivity and state preservation bugs.

## Unit Tests (30 tests)

- **snapshotOutputs/restoreOutputs round-trip** — verifies previews
survive snapshot → reset → restore
- **snapshotOutputs deep clone** — mutating snapshot doesn't affect
store
- **resetAllOutputsAndPreviews** — clears all outputs and previews for
multiple nodes
- **restoreOutputs + execution interaction** — execution can add outputs
after restore; documents current overwrite behavior (baseline for
#9123's guard)
- **merge mode + input preview** — merge concatenates images; empty
merge doesn't duplicate
- **setNodeOutputs widget path** — early return on empty string/null
node; valid filename; empty array
- **Tab-switch output preservation** — simulates the ChangeTracker
store/restore cycle (snapshot → reset → restore) to verify outputs and
previews survive tab switches, replacing previously broken E2E tests

## Verification

- All 30 unit tests pass: `npx vitest run
src/stores/nodeOutputStore.test.ts`
- Browser typecheck passes: `pnpm typecheck:browser`
- ESLint clean

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Dante <bunggl@naver.com>
This commit is contained in:
Christian Byrne
2026-03-17 01:48:37 -07:00
committed by GitHub
parent d6c1dd2e59
commit b0d7f38caa

View File

@@ -325,6 +325,329 @@ describe('nodeOutputStore getPreviewParam', () => {
})
})
describe('nodeOutputStore snapshotOutputs / restoreOutputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should round-trip outputs through snapshot and restore', () => {
const store = useNodeOutputStore()
// Set input previews via execution path
const inputOutput = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('3', inputOutput)
const execOutput = createMockOutputs([
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('4', execOutput)
// Snapshot
const snapshot = store.snapshotOutputs()
// Clear everything
store.resetAllOutputsAndPreviews()
expect(Object.keys(app.nodeOutputs)).toHaveLength(0)
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
// Restore from snapshot
store.restoreOutputs(snapshot)
expect(app.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(app.nodeOutputs['4']).toStrictEqual(execOutput)
expect(store.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(store.nodeOutputs['4']).toStrictEqual(execOutput)
})
it('should preserve outputs across a simulated tab switch cycle', () => {
const store = useNodeOutputStore()
// Tab A: execution produces outputs for two nodes
const outputA1 = createMockOutputs([
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
])
const outputA2 = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('1', outputA1)
store.setNodeOutputsByExecutionId('3', outputA2)
// --- Switch away: store() then clean ---
const tabASnapshot = store.snapshotOutputs()
store.resetAllOutputsAndPreviews()
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
expect(Object.keys(app.nodeOutputs)).toHaveLength(0)
// Tab B: fresh empty workflow (no outputs)
const tabBSnapshot = store.snapshotOutputs()
expect(Object.keys(tabBSnapshot)).toHaveLength(0)
// --- Switch back to Tab A: store Tab B then restore Tab A ---
store.resetAllOutputsAndPreviews()
store.restoreOutputs(tabASnapshot)
// Tab A's outputs should be fully restored
expect(store.nodeOutputs['1']).toStrictEqual(outputA1)
expect(store.nodeOutputs['3']).toStrictEqual(outputA2)
expect(app.nodeOutputs['1']).toStrictEqual(outputA1)
expect(app.nodeOutputs['3']).toStrictEqual(outputA2)
// New execution should still work after restore
const newOutput = createMockOutputs([{ filename: 'new.png' }])
store.setNodeOutputsByExecutionId('5', newOutput)
expect(store.nodeOutputs['5']).toStrictEqual(newOutput)
})
it('should keep tab outputs independent across multiple switches', () => {
const store = useNodeOutputStore()
// Tab A: execute
const outputA = createMockOutputs([{ filename: 'tab_a.png' }])
store.setNodeOutputsByExecutionId('1', outputA)
const snapshotA = store.snapshotOutputs()
// Switch to Tab B
store.resetAllOutputsAndPreviews()
const outputB = createMockOutputs([{ filename: 'tab_b.png' }])
store.setNodeOutputsByExecutionId('1', outputB)
const snapshotB = store.snapshotOutputs()
// Switch back to Tab A
store.resetAllOutputsAndPreviews()
store.restoreOutputs(snapshotA)
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('tab_a.png')
// Switch back to Tab B
const snapshotA2 = store.snapshotOutputs()
store.resetAllOutputsAndPreviews()
store.restoreOutputs(snapshotB)
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('tab_b.png')
// And back to Tab A again - still correct
store.resetAllOutputsAndPreviews()
store.restoreOutputs(snapshotA2)
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('tab_a.png')
})
it('should return a deep clone from snapshotOutputs', () => {
const store = useNodeOutputStore()
const output = createMockOutputs([{ filename: 'a.png' }])
store.setNodeOutputsByExecutionId('1', output)
const snapshot = store.snapshotOutputs()
// Mutate the snapshot
snapshot['1'].images![0].filename = 'mutated.png'
snapshot['99'] = createMockOutputs([{ filename: 'new.png' }])
// Store should be unchanged
expect(store.nodeOutputs['1']?.images?.[0]?.filename).toBe('a.png')
expect(app.nodeOutputs['1']?.images?.[0]?.filename).toBe('a.png')
expect(store.nodeOutputs['99']).toBeUndefined()
})
})
describe('nodeOutputStore resetAllOutputsAndPreviews', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should clear all outputs and previews for multiple nodes', () => {
const store = useNodeOutputStore()
store.setNodeOutputsByExecutionId(
'1',
createMockOutputs([{ filename: 'a.png' }])
)
store.setNodeOutputsByExecutionId(
'2',
createMockOutputs([{ filename: 'b.png' }])
)
store.setNodeOutputsByExecutionId(
'3',
createMockOutputs([{ filename: 'c.png', type: 'input' }])
)
expect(Object.keys(store.nodeOutputs)).toHaveLength(3)
expect(Object.keys(app.nodeOutputs)).toHaveLength(3)
store.resetAllOutputsAndPreviews()
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
expect(Object.keys(app.nodeOutputs)).toHaveLength(0)
expect(Object.keys(app.nodePreviewImages)).toHaveLength(0)
})
})
describe('nodeOutputStore restoreOutputs + execution interaction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should allow execution to update outputs after restore', () => {
const store = useNodeOutputStore()
// Simulate tab restore with existing input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
const savedOutputs: Record<string, ExecutedWsMessage['output']> = {
'3': inputOutput
}
store.restoreOutputs(savedOutputs)
expect(store.nodeOutputs['3']).toStrictEqual(inputOutput)
// Simulate execution sending new output for a different node
const execOutput = createMockOutputs([
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('4', execOutput)
// Both should be present
expect(store.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(store.nodeOutputs['4']).toStrictEqual(execOutput)
expect(app.nodeOutputs['3']).toStrictEqual(inputOutput)
expect(app.nodeOutputs['4']).toStrictEqual(execOutput)
})
it('should overwrite existing output when execution sends new data for same node', () => {
const store = useNodeOutputStore()
// Restore with input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
store.restoreOutputs({ '3': inputOutput })
// Execution sends new output for the same node (non-merge)
const execOutput = createMockOutputs([
{ filename: 'result.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('3', execOutput)
// On current main (without PR #9123 guard), execution overwrites
expect(store.nodeOutputs['3']).toStrictEqual(execOutput)
expect(app.nodeOutputs['3']).toStrictEqual(execOutput)
})
})
describe('nodeOutputStore merge mode interactions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should merge new images with existing input preview images', () => {
const store = useNodeOutputStore()
// Set initial input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('3', inputOutput)
// Merge new execution images
const execOutput = createMockOutputs([
{ filename: 'result.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId('3', execOutput, { merge: true })
// Should have both images concatenated
expect(store.nodeOutputs['3']?.images).toHaveLength(2)
expect(app.nodeOutputs['3']?.images).toHaveLength(2)
expect(store.nodeOutputs['3']?.images?.[0]?.filename).toBe('uploaded.png')
expect(store.nodeOutputs['3']?.images?.[1]?.filename).toBe('result.png')
})
it('should not duplicate when merge is called with empty images array', () => {
const store = useNodeOutputStore()
// Set initial input preview
const inputOutput = createMockOutputs([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('3', inputOutput)
// Merge with empty images — the input-preview guard (lines 166-177)
// copies existing input images into the incoming outputs before the
// merge concat runs, resulting in duplication.
const emptyOutput = createMockOutputs([])
store.setNodeOutputsByExecutionId('3', emptyOutput, { merge: true })
expect(store.nodeOutputs['3']?.images).toHaveLength(2)
expect(store.nodeOutputs['3']?.images?.[0]?.filename).toBe('uploaded.png')
expect(store.nodeOutputs['3']?.images?.[1]?.filename).toBe('uploaded.png')
})
})
describe('nodeOutputStore setNodeOutputs (widget path)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should return early for empty string filename', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, '')
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
it('should return early for null node', () => {
const store = useNodeOutputStore()
store.setNodeOutputs(null as unknown as LGraphNode, 'test.png')
expect(Object.keys(store.nodeOutputs)).toHaveLength(0)
})
it('should set outputs for valid string filename', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, 'test.png')
expect(store.nodeOutputs['5']).toBeDefined()
expect(store.nodeOutputs['5']?.images).toHaveLength(1)
expect(store.nodeOutputs['5']?.images?.[0]?.filename).toBe('test.png')
expect(store.nodeOutputs['5']?.images?.[0]?.type).toBe('input')
})
it('should skip empty array of filenames after createOutputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, [])
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
})
describe('nodeOutputStore syncLegacyNodeImgs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))