Compare commits

...

1 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
11 changed files with 3443 additions and 24 deletions

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
}