Compare commits

...

9 Commits

Author SHA1 Message Date
huang47
83be5e4efb test: target DrJKL CodeRabbit canary cases
Apply only the PR #13362 files needed to exercise the two DrJKL review patterns against the updated #13486 CodeRabbit instructions: app queue credential cleanup coverage and direct LGraph.configure() sparse-link coverage. Include the companion type-fix files required for the subset to pass typecheck.

Verification: staged the targeted PR #13362 files plus typecheck dependencies; ran git diff --cached --check successfully; pre-commit ran oxfmt, oxlint, eslint, and pnpm typecheck.
2026-07-07 20:03:17 -07:00
huang47
ae6d29f4e8 chore: add CodeRabbit app queue cleanup check
Add a deterministic CodeRabbit pre-merge check for the DrJKL app queuePrompt finding: when app.ts temporarily assigns api.authToken/api.apiKey around api.queuePrompt, require rejected-path tests that prove both credentials are cleared. Path instructions alone did not reliably surface this on the canary.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file and confirmed the custom check list includes App queue credential cleanup on rejection; ran git diff --check successfully.
2026-07-07 20:02:38 -07:00
huang47
676c3c5f1d chore: tighten CodeRabbit app queue rejection instruction
Strengthen app-specific CodeRabbit guidance after the canary still missed DrJKL’s rejected queuePrompt credential cleanup finding. Add production-path guidance for src/scripts/app.ts and make rejection-without-cleanup assertions explicitly reviewable.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected diff to confirm only app queue cleanup guidance changed.
2026-07-07 18:56:09 -07:00
huang47
02f7a27215 chore: add CodeRabbit LGraph configure instruction
Add targeted LGraph production-path guidance so CodeRabbit asks for direct configure() regression coverage when deserialization or nullable link handling changes. This targets the DrJKL review pattern where helper-only null-link coverage did not prove graph deserialization behavior.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected diff to confirm only src/lib/litegraph/src/LGraph.ts path guidance changed.
2026-07-07 18:34:36 -07:00
huang47
f4526fdd8f chore: add CodeRabbit app queue cleanup instruction
Add a targeted app test path instruction for queuePrompt reviews so CodeRabbit asks for rejected-path authToken/apiKey cleanup coverage, matching the DrJKL review pattern on app.core.test.ts.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected diff to confirm only src/scripts/app*.test.ts path guidance changed.
2026-07-07 18:34:05 -07:00
huang47
7ef845bda7 chore: sharpen CodeRabbit Vitest failure-path review
Teach the shared Vitest path instruction to require rejected-path cleanup assertions and direct runtime-entrypoint coverage. This targets the missed DrJKL pattern where success cleanup existed but rejected queuePrompt cleanup was unverified.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected staged diff to confirm only the shared Vitest path instruction changed.
2026-07-07 18:33:09 -07:00
huang47
3e043acfdb chore: add CodeRabbit Playwright path instruction
Route changed browser_tests/**/*.spec.ts and apps/website/e2e/**/*.spec.ts files through CodeRabbit guidance that explicitly names .agents/checks/test-quality.md and docs/testing/README.md as required review context.

Verification: parsed .coderabbit.yaml with Ruby YAML, ran git diff --check, and ran pnpm typecheck in this worktree.
2026-07-07 15:16:37 -07:00
huang47
0b66a8124d chore: add CodeRabbit litegraph test path instruction
Move litegraph-only test guidance out of the blanket **/*.test.ts rule and into src/lib/litegraph/**/*.test.ts so unrelated Vitest files are not nudged toward litegraph factories or litegraph class mocking rules.

Verification: parsed .coderabbit.yaml with Ruby YAML, ran git diff --check, and ran pnpm typecheck in this worktree.
2026-07-07 15:11:58 -07:00
huang47
bba941b112 chore: add CodeRabbit Vitest path instruction
Route changed .test.ts files through CodeRabbit guidance that explicitly names .agents/checks/test-quality.md, docs/testing/README.md, and docs/guidance/vitest.md as required review context.

Verification: parsed .coderabbit.yaml with Ruby YAML, ran git diff --check, and ran pnpm typecheck in this worktree.
2026-07-07 15:11:30 -07:00
12 changed files with 3502 additions and 24 deletions

View File

@@ -63,3 +63,62 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
- name: App queue credential cleanup on rejection
mode: warning
instructions: |
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
This check applies ONLY when the PR changes `src/scripts/app.ts` and that diff assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`.
When applicable, require a changed app test file such as `src/scripts/app.core.test.ts` or `src/scripts/app.test.ts` to include rejected-queue coverage that:
1. Populates both credential sources (`authToken` and API key, or the stores that feed them) with non-empty values.
2. Makes `api.queuePrompt` reject.
3. Calls `app.queuePrompt`.
4. Asserts after the rejection path that both `api.authToken` and `api.apiKey` are cleared, deleted, or `undefined`.
Warn if rejection is tested without those cleanup assertions, or if cleanup is asserted only on the successful `api.queuePrompt` path. Mention that success-path cleanup alone does not prove credentials are cleared after a failed queue request.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
Flag missing behavioral coverage for changed behavior, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and unrestored global mutations.
Prefer colocated behavioral tests named after the source file.
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny().
Mock only at seams (Pinia stores, settings, third-party libs); flag mocked type guards or sibling composables.
Use a real createI18n instance rather than vi.mock('vue-i18n').
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, and .mock.results assertions.
For rejected promises, thrown errors, and failed async calls, require assertions for post-error state cleanup and side-effect rollback, especially auth tokens, API keys, globals, listeners, timers, subscriptions, and caches that production mutates before awaiting.
Tests for production changes must exercise the changed runtime/public entrypoint directly; flag helper-only coverage when the changed branch runs through a higher-level entrypoint.
Use @testing-library/vue for component tests, not @vue/test-utils.
For platform-owned types (Response, CustomEvent, DOM events), require real instances (new Response(), Response.json(), new CustomEvent()) instead of fromPartial or casts.
When a fixture cast hides a too-wide production signature, suggest narrowing the production type instead of casting the fixture.
Flag process-level listeners (process.on('unhandledRejection')) in tests; assert the rejected promise directly.
Tests should import the module under test from its public entrypoint, not deep internal paths.
- path: 'src/scripts/app*.test.ts'
instructions: |
For app tests that mock or spy on `api.queuePrompt`, require rejected-queue coverage whenever the code temporarily populates `api.authToken` or `api.apiKey`.
The rejected-path test must populate both values, make `api.queuePrompt` reject, and assert both fields are cleared afterward; success-path cleanup alone is insufficient.
If `api.queuePrompt` rejection is tested without asserting `api.authToken` and `api.apiKey` cleanup afterward, flag the missing assertion even when a success-path test already covers cleanup.
- path: 'src/scripts/app.ts'
instructions: |
When app queuing code temporarily assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`, require app tests for both resolved and rejected `api.queuePrompt` paths.
The rejected-path test must prove both credential fields are cleared after `api.queuePrompt` rejects; a rejection assertion without cleanup checks is insufficient.
- path: 'src/lib/litegraph/src/LGraph.ts'
instructions: |
When changes touch `LGraph.configure()`, graph deserialization, link loading, or nullable serialized links, require a direct regression test that constructs an `LGraph` and calls `configure()` with the changed serialized shape.
Helper-only coverage is insufficient for these changes. For sparse legacy links, expect a v0.4 workflow with `links: [null, validLink]` to not throw and still create the valid link.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed litegraph Vitest test file.
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling litegraph mock builders.
Flag mocked litegraph classes when a real instance or shared factory would exercise behavior directly.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md` and `docs/testing/README.md` as required review context for every changed Playwright test file.
Flag missing behavioral coverage, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and test isolation problems.
Every route.fulfill() body must be typed with generated types or schemas from packages/ingest-types, packages/registry-types, src/workbench/extensions/manager/types/generatedManagerTypes.ts, or src/schemas/; flag untyped inline JSON objects.
Never use waitForTimeout; use Locator actions and auto-retrying assertions instead.
Restrict page.evaluate() to reading internal state or fixture setup; flag any page.evaluate() that drives UI actions when a Playwright action method exists.
New shared test helpers must be Playwright fixtures via base.extend(), not properties added to ComfyPage.

View File

@@ -260,7 +260,6 @@ useExtensionService().registerExtension({
if (!isLoad3dNode(selectedNode)) return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
const props = { node: selectedNode }

View File

@@ -2494,6 +2494,7 @@ export class LGraph
// Deprecated - old schema version, links are arrays
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
if (!linkData) continue
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
}

View File

@@ -133,7 +133,7 @@ export interface ISerialisedGraph extends BaseExportedGraph {
last_node_id: SerializedNodeId
last_link_id: number
nodes: ISerialisedNode[]
links: SerialisedLLinkArray[]
links: (SerialisedLLinkArray | null)[]
floatingLinks?: SerialisableLLink[]
groups: ISerialisedGroup[]
version: typeof LiteGraph.VERSION

View File

@@ -21,7 +21,7 @@ const { isCreditsBadge } = usePriceBadge()
const { t } = useI18n()
const creditsBadges = computed(() =>
mapAllNodes(app.graph, (node) => {
mapAllNodes(app.rootGraph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)

3124
src/scripts/app.core.test.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -244,7 +244,7 @@ export class ComfyApp {
static clipspace_invalidate_handler: (() => void) | null = null
static open_maskeditor: (() => void) | null = null
static maskeditor_is_opended: (() => void) | null = null
static clipspace_return_node = null
static clipspace_return_node: LGraphNode | null = null
vueAppReady: boolean
api: ComfyApi
@@ -257,8 +257,8 @@ export class ComfyApp {
// TODO: Migrate internal usage to the
/** @deprecated Use {@link rootGraph} instead */
get graph() {
return this.rootGraphInternal!
get graph(): LGraph | undefined {
return this.rootGraphInternal
}
get rootGraph(): LGraph {
@@ -1167,7 +1167,7 @@ export class ComfyApp {
}
async loadGraphData(
graphData?: ComfyWorkflowJSON,
graphData?: ComfyWorkflowJSON | unknown[],
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,

View File

@@ -74,7 +74,7 @@ describe('useSubgraphNavigationStore', () => {
app.canvas.ds.offset = [0, 0]
app.canvas.ds.state.scale = 1
app.canvas.ds.state.offset = [0, 0]
app.graph.getNodeById = vi.fn()
app.rootGraph.getNodeById = vi.fn()
})
it('should not clear navigation stack when workflow internal state changes', async () => {
@@ -223,7 +223,7 @@ describe('useSubgraphNavigationStore', () => {
const unreachableSubgraph = createMockSubgraph('orphan-subgraph', app.graph)
app.graph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
app.rootGraph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
vi.mocked(findSubgraphPathById).mockReturnValue(null)
const mockWorkflow = fromPartial<ComfyWorkflow>({
@@ -251,7 +251,7 @@ describe('useSubgraphNavigationStore', () => {
const mockSubgraph = createMockSubgraph('subgraph-1', app.graph)
// Add the subgraph to the graph's subgraphs map
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
app.rootGraph.subgraphs.set('subgraph-1', mockSubgraph)
// First set an active workflow
const mockWorkflow = fromPartial<ComfyWorkflow>({

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -89,7 +90,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
return rafCallbacks.length
})
mockCanvas.subgraph = undefined
mockCanvas.graph = app.graph
mockCanvas.graph = app.graph ?? null
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.state.scale = 1
@@ -136,6 +137,23 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('saveViewport', () => {
it('does not save when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
try {
store.saveViewport('root')
expect(store.viewportCache.has(':root')).toBe(false)
} finally {
appWithOptionalCanvas.canvas = canvas
}
})
it('saves viewport state for root graph', () => {
const store = useSubgraphNavigationStore()
mockCanvas.ds.state.scale = 2
@@ -164,6 +182,42 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('restoreViewport', () => {
it('does nothing when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
try {
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(0)
} finally {
appWithOptionalCanvas.canvas = canvas
}
})
it('does not apply cached viewport when canvas disappears', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as {
canvas: typeof app.canvas | undefined
}
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
appWithOptionalCanvas.canvas = undefined
try {
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
} finally {
appWithOptionalCanvas.canvas = canvas
}
})
it('restores cached viewport', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
@@ -266,7 +320,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockFitView).toHaveBeenCalledOnce()
// User navigated away before the inner RAF fired
mockCanvas.subgraph = { id: 'different-graph' } as never
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
@@ -283,7 +337,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(rafCallbacks).toHaveLength(1)
// Simulate graph switching away before rAF fires
mockCanvas.subgraph = { id: 'different-graph' } as never
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
rafCallbacks[0](performance.now())
@@ -296,12 +350,12 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const mockRootGraph = {
const mockRootGraph = fromPartial<LGraph>({
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
} as Partial<LGraph> as LGraph
})
const subgraph1 = {
id: 'sub1',
rootGraph: mockRootGraph,
@@ -313,7 +367,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
mockCanvas.ds.state.offset = [100, 100]
// Enter subgraph
workflowStore.activeSubgraph = subgraph1 as Partial<Subgraph> as Subgraph
workflowStore.activeSubgraph = fromPartial<Subgraph>(subgraph1)
await nextTick()
// Root viewport saved
@@ -341,6 +395,23 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('does not save the outgoing viewport while a workflow switch is blocked', async () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const subgraph = fromPartial<Subgraph>({
id: 'sub1',
isRootGraph: false,
rootGraph: app.rootGraph
})
store.saveCurrentViewport()
store.viewportCache.clear()
workflowStore.activeSubgraph = subgraph
await nextTick()
expect(store.viewportCache.has(':root')).toBe(false)
})
it('preserves pre-existing cache entries across workflow switches', async () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()

View File

@@ -1,4 +1,5 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import type { PartialDeep } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
@@ -8,6 +9,7 @@ import type {
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { LinkId } from '@/types/linkId'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
@@ -162,6 +164,26 @@ describe('fixBadLinks', () => {
expect(graph.nodes[1]?.inputs?.[0]?.link).toBe(1)
})
it('reports a missing target input link during a dry run', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1, outputs: [createOutput([1])] }),
createNode({ id: 2, inputs: [createInput(null)] })
],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph)
expect(result).toMatchObject({
hasBadLinks: true,
fixed: false,
patched: 1,
deleted: 0
})
expect(graph.nodes[1]?.inputs?.[0]?.link).toBeNull()
})
it('removes the origin reference when the target input slot is missing', () => {
const graph = createGraph({
nodes: [
@@ -206,6 +228,53 @@ describe('fixBadLinks', () => {
expect(graph.links).toEqual([])
})
it('keeps the later target link when two links target the same input slot', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1, outputs: [createOutput([1])] }),
createNode({ id: 2, outputs: [createOutput([2])] }),
createNode({ id: 3, inputs: [createInput(null)] })
],
links: [
[1, 1, 0, 3, 0, '*'],
[2, 2, 0, 3, 0, '*']
]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
deleted: 1
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([])
expect(graph.nodes[1]?.outputs?.[0]?.links).toEqual([2])
expect(graph.nodes[2]?.inputs?.[0]?.link).toBe(2)
expect(graph.links).toEqual([[2, 2, 0, 3, 0, '*']])
})
it('reports stale origin references during a dry run', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1, outputs: [createOutput([1])] }),
createNode({ id: 2, inputs: [createInput(2)] })
],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph)
expect(result).toMatchObject({
hasBadLinks: true,
fixed: false,
patched: 1,
deleted: 1
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1])
expect(graph.links).toEqual([[1, 1, 0, 2, 0, '*']])
})
it('cleans dangling references when a linked node is missing', () => {
const graph = createGraph({
nodes: [createNode({ id: 2, inputs: [createInput(1)] })],
@@ -224,6 +293,24 @@ describe('fixBadLinks', () => {
expect(graph.links).toEqual([])
})
it('deletes missing-origin links when the target does not reference them', () => {
const graph = createGraph({
nodes: [createNode({ id: 2, inputs: [createInput(null)] })],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.nodes[0]?.inputs?.[0]?.link).toBeNull()
expect(graph.links).toEqual([])
})
it('cleans dangling origin references when the target node is missing', () => {
const graph = createGraph({
nodes: [createNode({ id: 1, outputs: [createOutput([1])] })],
@@ -242,6 +329,24 @@ describe('fixBadLinks', () => {
expect(graph.links).toEqual([])
})
it('deletes missing-target links when the origin does not reference them', () => {
const graph = createGraph({
nodes: [createNode({ id: 1, outputs: [createOutput([])] })],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([])
expect(graph.links).toEqual([])
})
it('deletes a stale link that neither endpoint references', () => {
const graph = createGraph({
nodes: [
@@ -264,10 +369,10 @@ describe('fixBadLinks', () => {
it('deletes stale links from live graph link maps', () => {
const linkId = toLinkId(1)
const originNode = fromAny<LGraphNode, unknown>({
const originNode = fromPartial<LGraphNode>({
id: toNodeId(1),
outputs: [{ links: [linkId] }]
})
} as PartialDeep<LGraphNode>)
const link = fromPartial<LLink>({
id: linkId,
origin_id: originNode.id,
@@ -276,13 +381,14 @@ describe('fixBadLinks', () => {
target_slot: 0,
type: '*'
})
const links = new Map([[linkId, link]])
const graph = fromAny<LGraph, unknown>({
const links = new Map([[linkId, link]]) as Map<LinkId, LLink> &
Record<LinkId, LLink>
const graph = fromPartial<LGraph>({
links,
getNodeById: vi.fn((nodeId) =>
nodeId === originNode.id ? originNode : null
)
})
} as PartialDeep<LGraph>)
const result = fixBadLinks(graph, { fix: true, silent: true })
@@ -317,4 +423,118 @@ describe('fixBadLinks', () => {
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1])
expect(logger.log).not.toHaveBeenCalled()
})
it('creates missing origin output slots in fix mode', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1 }),
createNode({ id: 2, inputs: [createInput(1)] })
],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 1,
deleted: 0
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1])
})
it('deletes links whose serialized endpoints are both missing', () => {
const graph = createGraph({
nodes: [],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.links).toEqual([])
})
it('ignores null serialized link entries', () => {
const graph = {
...createGraph({
nodes: [createNode({ id: 1 })],
links: []
}),
links: [null]
}
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: false,
patched: 0,
deleted: 0
})
expect(graph.links).toEqual([])
})
it('deletes object-shaped serialized links', () => {
const objectLink = {
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: '*'
}
const graph = fromPartial<ISerialisedGraph>({
...createGraph({
nodes: [],
links: []
})
} as PartialDeep<ISerialisedGraph>)
Reflect.set(graph, 'links', [objectLink])
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.links).toEqual([])
})
it('treats invalid live graph endpoint ids as missing', () => {
const linkId = toLinkId(1)
const link = fromPartial<LLink>({
id: linkId,
origin_id: toNodeId(''),
origin_slot: 0,
target_id: toNodeId(''),
target_slot: 0,
type: '*'
})
const links = new Map([[linkId, link]]) as Map<LinkId, LLink> &
Record<LinkId, LLink>
const graph = fromPartial<LGraph>({
links,
getNodeById: vi.fn()
} as PartialDeep<LGraph>)
const result = fixBadLinks(graph, { fix: true, silent: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.getNodeById).not.toHaveBeenCalled()
expect(links.has(linkId)).toBe(false)
})
})

View File

@@ -283,7 +283,9 @@ export function fixBadLinks(
return hasAny
}
const links: Array<SerialisedLLinkArray | LLink> = Array.isArray(graph.links)
const links: Array<SerialisedLLinkArray | LLink | null> = Array.isArray(
graph.links
)
? graph.links
: Array.from((graph as LGraph).links.values())

View File

@@ -269,7 +269,9 @@ export function compressWidgetInputSlots(graph: ISerialisedGraph) {
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
if (input.link) {
const link = graph.links.find((link) => link[0] === input.link)
const link = graph.links.find(
(link) => link !== null && link[0] === input.link
)
if (link) {
link[4] = inputIndex
}