mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-14 01:36:14 +00:00
test(validation): add unit coverage for useWorkflowValidation and linkRepair abort paths
Codecov flagged the patch at 53% with the new `useWorkflowValidation` composable at 1.85% and `linkRepair`'s new abort branches uncovered. This adds: - `useWorkflowValidation.test.ts` (8 tests) covering: schema-fail fallback, no-error happy path, topology summary toast capping at `TOPOLOGY_TOAST_LIMIT`, links-fixed success toast, abort returning `null`, re-throw of unexpected exceptions, the clone-before-repair contract, and silent-mode suppression. `vue-i18n`, the toast store, the package, the schema validator, and `@/scripts/utils` are all mocked out. - `linkRepair.test.ts` (5 tests) covering the `LinkRepairAbortedError` shape, its formatted message for every `TopologyError.kind`, the live-graph branch (`getNodeById` plus record-shaped `graph.links`), and the not-found `continue` path in the splice loop.
This commit is contained in:
166
packages/workflow-validation/src/linkRepair.test.ts
Normal file
166
packages/workflow-validation/src/linkRepair.test.ts
Normal file
@@ -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<SerialisedLinkArray | SerialisedLinkObject>
|
||||
): 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<number, SerialisedLinkObject> = {
|
||||
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<number, SerialisedLinkObject>)[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]')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> }).named
|
||||
: (last as Record<string, unknown> | 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<typeof WorkflowValidationModule>(
|
||||
'@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: <T>(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> = {}
|
||||
): 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user