diff --git a/packages/workflow-validation/src/linkRepair.test.ts b/packages/workflow-validation/src/linkRepair.test.ts new file mode 100644 index 0000000000..7d26b2db7a --- /dev/null +++ b/packages/workflow-validation/src/linkRepair.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' + +import { LinkRepairAbortedError, repairLinks } from './linkRepair' +import type { + SerialisedGraph, + SerialisedLinkArray, + SerialisedLinkObject, + SerialisedNode, + SerialisedNodeInput, + SerialisedNodeOutput +} from './serialised' + +function input(link: number | null): SerialisedNodeInput { + return { name: 'i', type: '*', link } +} + +function output(links: number[]): SerialisedNodeOutput { + return { name: 'o', type: '*', links } +} + +function makeGraph( + nodes: SerialisedNode[], + links: Array +): SerialisedGraph { + return { nodes, links } +} + +describe('repairLinks abort behaviour', () => { + it('throws LinkRepairAbortedError carrying the topology context when the patched view diverges from the live graph', () => { + const node1: SerialisedNode = { + id: 1, + outputs: [output([10, 11])] + } + const node2: SerialisedNode = { + id: 2, + inputs: [input(null)] + } + const graph = makeGraph( + [node1, node2], + [ + [10, 1, 0, 2, 0, '*'], + [11, 1, 0, 2, 0, '*'] + ] + ) + + let thrown: unknown + try { + repairLinks(graph, { fix: true, silent: true }) + } catch (err) { + thrown = err + } + if (thrown instanceof LinkRepairAbortedError) { + expect(thrown.topologyError.link.linkId).toBeGreaterThan(0) + expect(typeof thrown.message).toBe('string') + } + }) + + it('LinkRepairAbortedError exposes a topologyError discriminated union', () => { + const err = new LinkRepairAbortedError({ + kind: 'target-link-mismatch', + link: { + linkId: 99, + originId: 1, + originSlot: 0, + targetId: 2, + targetSlot: 0 + }, + actualLink: 5 + }) + expect(err.topologyError.kind).toBe('target-link-mismatch') + expect(err.message).toContain('[link=99 src=1:0 tgt=2:0]') + expect(err.name).toBe('LinkRepairAbortedError') + }) +}) + +describe('repairLinks delete-with-missing-index path', () => { + it('does not corrupt the link array when the deleted link disappears mid-iteration', () => { + const node1: SerialisedNode = { id: 1, outputs: [output([99])] } + const node2: SerialisedNode = { id: 2, inputs: [input(99)] } + const graph: SerialisedGraph = { + nodes: [node1, node2], + links: [ + [42, 1, 0, 2, 5, '*'], + [99, 1, 0, 2, 0, '*'] + ] + } + + repairLinks(graph, { fix: true, silent: true }) + + const surviving = graph.links.find( + (l): l is SerialisedLinkArray => + Array.isArray(l) && (l as SerialisedLinkArray)[0] === 99 + ) + expect(surviving).toBeDefined() + }) +}) + +describe('repairLinks live-graph branch', () => { + it('uses graph.getNodeById and treats links as a record when the live-graph hook is present', () => { + const node1: SerialisedNode = { + id: 1, + outputs: [output([])] + } + const node2: SerialisedNode = { + id: 2, + inputs: [input(null)] + } + const links: Record = { + 42: { + id: 42, + origin_id: 999, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: '*' + } + } + const liveGraph = { + nodes: [node1, node2], + links: links as unknown as SerialisedGraph['links'], + getNodeById: (id: string | number) => + [node1, node2].find((n) => n.id == id) + } as SerialisedGraph & { + getNodeById: (id: string | number) => SerialisedNode | undefined + } + + repairLinks(liveGraph, { fix: true, silent: true }) + + expect((links as Record)[42]).toBeUndefined() + }) +}) + +describe('repairLinks describeTopologyError coverage via abort', () => { + it('produces a message tuple for every kind of LinkRepairAbortedError path', () => { + const link = { + linkId: 1, + originId: 1, + originSlot: 0, + targetId: 2, + targetSlot: 0 + } + const cases = [ + new LinkRepairAbortedError({ kind: 'missing-origin-node', link }), + new LinkRepairAbortedError({ kind: 'missing-target-node', link }), + new LinkRepairAbortedError({ + kind: 'origin-slot-out-of-bounds', + link, + originSlotCount: 2 + }), + new LinkRepairAbortedError({ + kind: 'target-slot-out-of-bounds', + link, + targetSlotCount: 4 + }), + new LinkRepairAbortedError({ kind: 'origin-link-not-listed', link }), + new LinkRepairAbortedError({ + kind: 'target-link-mismatch', + link, + actualLink: null + }) + ] + for (const err of cases) { + expect(err.message).toContain('[link=1 src=1:0 tgt=2:0]') + } + }) +}) diff --git a/src/platform/workflow/validation/composables/useWorkflowValidation.test.ts b/src/platform/workflow/validation/composables/useWorkflowValidation.test.ts new file mode 100644 index 0000000000..ae0a7ca3be --- /dev/null +++ b/src/platform/workflow/validation/composables/useWorkflowValidation.test.ts @@ -0,0 +1,257 @@ +import { LinkRepairAbortedError } from '@comfyorg/workflow-validation' +import type { + ComfyWorkflowJSON, + RepairResult, + TopologyError +} from '@comfyorg/workflow-validation' +import type * as WorkflowValidationModule from '@comfyorg/workflow-validation' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useWorkflowValidation } from './useWorkflowValidation' + +const toastAddMock = vi.hoisted(() => vi.fn()) +const toastAddAlertMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ + add: toastAddMock, + addAlert: toastAddAlertMock + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, ...rest: unknown[]) => { + const last = rest[rest.length - 1] + const params = + last && typeof last === 'object' && 'named' in (last as object) + ? (last as { named: Record }).named + : (last as Record | undefined) + if (!params) return key + return `${key}|${JSON.stringify(params)}` + } + }) +})) + +const validateLinkTopologyMock = vi.hoisted(() => vi.fn()) +const repairLinksMock = vi.hoisted(() => vi.fn()) +const describeTopologyErrorMock = vi.hoisted(() => + vi.fn((e: TopologyError) => `desc:${e.kind}:${e.link.linkId}`) +) + +vi.mock('@comfyorg/workflow-validation', async () => { + const actual = await vi.importActual( + '@comfyorg/workflow-validation' + ) + return { + ...actual, + validateLinkTopology: validateLinkTopologyMock, + repairLinks: repairLinksMock, + describeTopologyError: describeTopologyErrorMock + } +}) + +const validateComfyWorkflowMock = vi.hoisted(() => vi.fn()) +vi.mock('@/platform/workflow/validation/schemas/workflowSchema', () => ({ + validateComfyWorkflow: validateComfyWorkflowMock +})) + +vi.mock('@/scripts/utils', () => ({ + clone: (v: T): T => structuredClone(v) +})) + +function makeLink(linkId: number) { + return { + linkId, + originId: 1, + originSlot: 0, + targetId: 2, + targetSlot: 0 + } +} + +function makeWorkflow(): ComfyWorkflowJSON { + return { + version: 0.4, + last_node_id: 2, + last_link_id: 1, + nodes: [ + { id: 1, outputs: [{ name: 'o', type: '*', links: [1] }] }, + { id: 2, inputs: [{ name: 'i', type: '*', link: 1 }] } + ] as unknown as ComfyWorkflowJSON['nodes'], + links: [[1, 1, 0, 2, 0, '*']] as unknown as ComfyWorkflowJSON['links'] + } as ComfyWorkflowJSON +} + +function repairResult( + graph: ComfyWorkflowJSON, + overrides: Partial = {} +): RepairResult { + return { + graph: graph as unknown as RepairResult['graph'], + hasBadLinks: false, + fixed: false, + patched: 0, + deleted: 0, + ...overrides + } +} + +describe('useWorkflowValidation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + toastAddMock.mockClear() + toastAddAlertMock.mockClear() + validateLinkTopologyMock.mockReset() + repairLinksMock.mockReset() + describeTopologyErrorMock.mockClear() + validateComfyWorkflowMock.mockReset() + }) + + afterEach(() => vi.restoreAllMocks()) + + it('returns null when schema validation fails', async () => { + validateComfyWorkflowMock.mockImplementation(async (_d, onError) => { + onError('bad schema') + return null + }) + + const { validateWorkflow } = useWorkflowValidation() + const out = await validateWorkflow(makeWorkflow()) + + expect(out.graphData).toBeNull() + expect(toastAddAlertMock).toHaveBeenCalledWith('bad schema') + expect(repairLinksMock).not.toHaveBeenCalled() + }) + + it('passes through when schema validation succeeds and no topology errors exist', async () => { + const wf = makeWorkflow() + validateComfyWorkflowMock.mockResolvedValue(wf) + validateLinkTopologyMock.mockReturnValue([]) + repairLinksMock.mockImplementation((g) => repairResult(g)) + + const { validateWorkflow } = useWorkflowValidation() + const out = await validateWorkflow(wf) + + expect(out.graphData).not.toBeNull() + expect(toastAddMock).not.toHaveBeenCalled() + }) + + it('emits a single warn toast summarising up to TOPOLOGY_TOAST_LIMIT errors', async () => { + const wf = makeWorkflow() + const errors: TopologyError[] = Array.from({ length: 7 }, (_v, i) => ({ + kind: 'missing-origin-node', + link: makeLink(i + 1) + })) + validateComfyWorkflowMock.mockResolvedValue(wf) + validateLinkTopologyMock.mockReturnValue(errors) + repairLinksMock.mockImplementation((g) => repairResult(g)) + + const { validateWorkflow } = useWorkflowValidation() + await validateWorkflow(wf) + + const warns = toastAddMock.mock.calls.filter(([arg]) => + (arg as { summary: string }).summary.startsWith( + 'validation.topology.invalidLinks' + ) + ) + expect(warns).toHaveLength(1) + const detail = (warns[0]![0] as { detail: string }).detail + expect(detail).toContain('validation.topology.overflow') + expect(detail.split('\n')).toHaveLength(6) + }) + + it('shows the success toast when repair fixes links', async () => { + const wf = makeWorkflow() + validateComfyWorkflowMock.mockResolvedValue(wf) + validateLinkTopologyMock.mockReturnValue([]) + repairLinksMock.mockImplementation((g) => + repairResult(g, { fixed: true, patched: 2, deleted: 1 }) + ) + + const { validateWorkflow } = useWorkflowValidation() + await validateWorkflow(wf) + + expect(toastAddMock).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'success', + summary: expect.stringContaining( + 'validation.topology.linksFixedSummary' + ) + }) + ) + }) + + it('returns null and emits an error toast on LinkRepairAbortedError', async () => { + const wf = makeWorkflow() + const topologyError: TopologyError = { + kind: 'target-slot-out-of-bounds', + link: makeLink(7), + targetSlotCount: 5 + } + validateComfyWorkflowMock.mockResolvedValue(wf) + validateLinkTopologyMock.mockReturnValue([topologyError]) + repairLinksMock.mockImplementation(() => { + throw new LinkRepairAbortedError(topologyError) + }) + + const { validateWorkflow } = useWorkflowValidation() + const out = await validateWorkflow(wf) + + expect(out.graphData).toBeNull() + const errorToast = toastAddMock.mock.calls.find( + ([arg]) => (arg as { severity: string }).severity === 'error' + ) + expect(errorToast).toBeDefined() + expect((errorToast![0] as { summary: string }).summary).toContain( + 'validation.topology.abortedSummary' + ) + }) + + it('re-throws unexpected errors from repairLinks', async () => { + const wf = makeWorkflow() + validateComfyWorkflowMock.mockResolvedValue(wf) + validateLinkTopologyMock.mockReturnValue([]) + repairLinksMock.mockImplementation(() => { + throw new TypeError('boom') + }) + + const { validateWorkflow } = useWorkflowValidation() + await expect(validateWorkflow(wf)).rejects.toThrow(TypeError) + }) + + it('clones graphData before passing to repairLinks so the abort fallback is untouched', async () => { + const wf = makeWorkflow() + validateComfyWorkflowMock.mockResolvedValue(wf) + validateLinkTopologyMock.mockReturnValue([]) + let received: ComfyWorkflowJSON | undefined + repairLinksMock.mockImplementation((g: ComfyWorkflowJSON) => { + received = g + return repairResult(g) + }) + + const { validateWorkflow } = useWorkflowValidation() + await validateWorkflow(wf) + + expect(received).not.toBe(wf) + }) + + it('silent option suppresses toasts but still validates', async () => { + const wf = makeWorkflow() + validateComfyWorkflowMock.mockResolvedValue(wf) + validateLinkTopologyMock.mockReturnValue([ + { kind: 'missing-origin-node', link: makeLink(1) } + ]) + repairLinksMock.mockImplementation((g) => + repairResult(g, { fixed: true, patched: 1, deleted: 0 }) + ) + + const { validateWorkflow } = useWorkflowValidation() + const out = await validateWorkflow(wf, { silent: true }) + + expect(out.graphData).not.toBeNull() + expect(toastAddMock).not.toHaveBeenCalled() + expect(toastAddAlertMock).not.toHaveBeenCalled() + }) +})