mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-09 16:48:06 +00:00
Compare commits
9 Commits
v1.48.0
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83be5e4efb | ||
|
|
ae6d29f4e8 | ||
|
|
676c3c5f1d | ||
|
|
02f7a27215 | ||
|
|
f4526fdd8f | ||
|
|
7ef845bda7 | ||
|
|
3e043acfdb | ||
|
|
0b66a8124d | ||
|
|
bba941b112 |
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
3124
src/scripts/app.core.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user