mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-09 00:29:22 +00:00
Compare commits
8 Commits
codex/code
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df73672168 | ||
|
|
f2cd07a770 | ||
|
|
b4f796c109 | ||
|
|
e235e7b6c8 | ||
|
|
f90dcadf60 | ||
|
|
3a7ec3e1c5 | ||
|
|
acc3dc06ef | ||
|
|
aea043ab43 |
@@ -64,56 +64,51 @@ reviews:
|
||||
|
||||
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.
|
||||
Review changed tests in the context of production files changed in the same PR; flag missing behavioral coverage for changed behavior, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, shared mutable state, and test isolation problems.
|
||||
Prefer colocated behavioral tests named after the source file, and require tests for production changes to exercise the changed runtime or public entrypoint directly. Helper-only coverage is insufficient when the changed branch is reached through a higher-level runtime path.
|
||||
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions, `as never`, and fromAny().
|
||||
Mock only at seams (Pinia stores, settings, third-party libraries); 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.
|
||||
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, tautological assertions, 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.
|
||||
For tests around queuing or request flows that temporarily set `api.authToken` or `api.apiKey`, require rejected-path coverage that makes the awaited request reject and then asserts both credential fields are cleared. Success-path cleanup alone is insufficient.
|
||||
Tests that call setup methods which install document, canvas, window, or prototype listeners must remove those listeners or isolate the EventTarget per test; repeated listener setup followed by later event dispatches is order-dependent.
|
||||
Reuse established shared helpers such as `src/utils/__tests__/litegraphTestUtils.ts` and `src/utils/__tests__/executionErrorTestUtils.ts`; flag hand-rolled litegraph node/canvas/subgraph/workflow builders and inline `required_input_missing` fixtures when a shared helper exists.
|
||||
For store tests that exercise real store behavior, require `createTestingPinia({ stubActions: false })`; flag plain `createPinia()` unless the test explicitly needs a real Pinia plugin path.
|
||||
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.
|
||||
For platform-owned types and descriptors such as Response, CustomEvent, DOM events, navigator.clipboard, fetch, URL, console methods, timers, and browser APIs, require real instances or teardown that restores the original value or descriptor. `vi.clearAllMocks()` and `vi.unstubAllGlobals()` alone do not restore mocked implementations or descriptors changed with Object.defineProperty.
|
||||
Async helpers and callbacks must be awaited or asserted with `.resolves` / `.rejects`; flag dropped promises that only pass while the current implementation is synchronous.
|
||||
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.
|
||||
The rejected-path test must populate both credential sources, make `api.queuePrompt` reject, call `app.queuePrompt`, and prove both credential fields are cleared after rejection. Success-path cleanup alone 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.
|
||||
Helper-only coverage is insufficient. For sparse legacy links, expect a v0.4 payload with `links: [null, validLink]` to not throw and still create the valid link.
|
||||
- path: 'src/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
instructions: |
|
||||
When behavior changes which graph `PartnerNodesList` traverses, require a focused component test for `PartnerNodesList` itself.
|
||||
If the change switches from `app.graph` to `app.rootGraph`, the test should arrange different badge nodes in each graph and assert the rendered badge list comes from `rootGraph`. Coverage through a parent test that stubs `PartnerNodesList` is insufficient.
|
||||
- path: 'src/utils/litegraphUtil.ts'
|
||||
instructions: |
|
||||
When link-retargeting or widget-slot compression logic such as `compressWidgetInputSlots()` changes to guard nullable or sparse links, require direct coverage of the malformed legacy shape.
|
||||
For sparse links, tests should include `links: [null, validLink]` and assert the valid link is still retargeted while the null entry is ignored. Coverage in `linkFixer.test.ts` or other helper tests is insufficient for this production path.
|
||||
- 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.
|
||||
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling litegraph node, canvas, graph, subgraph, or workflow builders.
|
||||
Flag mocked litegraph classes when a real instance or shared factory would exercise behavior directly.
|
||||
When a PR changes litegraph graph deserialization, link loading, nullable serialized links, or sparse legacy link handling, require direct regression coverage for the changed runtime path. Helper-only coverage in a different utility is insufficient.
|
||||
For `LGraph.configure()` sparse legacy links, expect a test that constructs an `LGraph`, calls `configure()` with a v0.4 payload containing `links: [null, validLink]`, asserts it does not throw, and asserts the valid link is still created.
|
||||
For link-retargeting or widget-slot compression changes such as `compressWidgetInputSlots()`, expect a direct test of the compression path with `links: [null, validLink]` that proves the valid link is still retargeted while the null entry is ignored.
|
||||
- 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.
|
||||
|
||||
@@ -260,6 +260,7 @@ 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 }
|
||||
|
||||
@@ -133,7 +133,7 @@ export interface ISerialisedGraph extends BaseExportedGraph {
|
||||
last_node_id: SerializedNodeId
|
||||
last_link_id: number
|
||||
nodes: ISerialisedNode[]
|
||||
links: (SerialisedLLinkArray | null)[]
|
||||
links: SerialisedLLinkArray[]
|
||||
floatingLinks?: SerialisableLLink[]
|
||||
groups: ISerialisedGroup[]
|
||||
version: typeof LiteGraph.VERSION
|
||||
|
||||
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: LGraphNode | null = null
|
||||
static clipspace_return_node = 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(): LGraph | undefined {
|
||||
return this.rootGraphInternal
|
||||
get graph() {
|
||||
return this.rootGraphInternal!
|
||||
}
|
||||
|
||||
get rootGraph(): LGraph {
|
||||
@@ -1167,7 +1167,7 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
async loadGraphData(
|
||||
graphData?: ComfyWorkflowJSON | unknown[],
|
||||
graphData?: ComfyWorkflowJSON,
|
||||
clean: boolean = true,
|
||||
restore_view: boolean = true,
|
||||
workflow: string | null | ComfyWorkflow = null,
|
||||
@@ -1667,10 +1667,11 @@ export class ComfyApp {
|
||||
try {
|
||||
api.authToken = comfyOrgAuthToken
|
||||
api.apiKey = comfyOrgApiKey ?? undefined
|
||||
const res = await api.queuePrompt(number, p, {
|
||||
const queueOptions = {
|
||||
partialExecutionTargets: queueNodeIds,
|
||||
previewMethod
|
||||
})
|
||||
}
|
||||
const res = await api.queuePrompt(number, p, queueOptions)
|
||||
delete api.authToken
|
||||
delete api.apiKey
|
||||
const nodeErrors = res.node_errors
|
||||
|
||||
@@ -11,6 +11,11 @@ global.URL = {
|
||||
|
||||
describe('mediaCacheService', () => {
|
||||
describe('URL reference counting', () => {
|
||||
it('keeps the global mocks available', () => {
|
||||
expect(global.fetch).toBeDefined()
|
||||
expect(global.URL.createObjectURL(new Blob())).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('should handle URL acquisition for non-existent cache entry', () => {
|
||||
const { acquireUrl } = useMediaCache()
|
||||
|
||||
|
||||
@@ -44,6 +44,29 @@ describe('executionErrorStore — node error operations', () => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('stores required input missing node errors', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'prompt' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.lastNodeErrors['1'].errors[0].type).toBe(
|
||||
'required_input_missing'
|
||||
)
|
||||
})
|
||||
|
||||
describe('clearSimpleNodeErrors', () => {
|
||||
it('does nothing if lastNodeErrors is null', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
@@ -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.rootGraph.getNodeById = vi.fn()
|
||||
app.graph.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.rootGraph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
|
||||
app.graph.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.rootGraph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
|
||||
// First set an active workflow
|
||||
const mockWorkflow = fromPartial<ComfyWorkflow>({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -90,7 +89,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
return rafCallbacks.length
|
||||
})
|
||||
mockCanvas.subgraph = undefined
|
||||
mockCanvas.graph = app.graph ?? null
|
||||
mockCanvas.graph = app.graph
|
||||
mockCanvas.ds.scale = 1
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
mockCanvas.ds.state.scale = 1
|
||||
@@ -137,23 +136,6 @@ 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
|
||||
@@ -182,42 +164,6 @@ 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] })
|
||||
@@ -320,7 +266,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// User navigated away before the inner RAF fired
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
rafCallbacks[1](performance.now())
|
||||
|
||||
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
|
||||
@@ -337,7 +283,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
// Simulate graph switching away before rAF fires
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
@@ -350,12 +296,12 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const mockRootGraph = fromPartial<LGraph>({
|
||||
const mockRootGraph = {
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
})
|
||||
} as Partial<LGraph> as LGraph
|
||||
const subgraph1 = {
|
||||
id: 'sub1',
|
||||
rootGraph: mockRootGraph,
|
||||
@@ -367,7 +313,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
mockCanvas.ds.state.offset = [100, 100]
|
||||
|
||||
// Enter subgraph
|
||||
workflowStore.activeSubgraph = fromPartial<Subgraph>(subgraph1)
|
||||
workflowStore.activeSubgraph = subgraph1 as Partial<Subgraph> as Subgraph
|
||||
await nextTick()
|
||||
|
||||
// Root viewport saved
|
||||
@@ -395,23 +341,6 @@ 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,5 +1,4 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import type { PartialDeep } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -9,7 +8,6 @@ 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'
|
||||
|
||||
@@ -164,26 +162,6 @@ 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: [
|
||||
@@ -228,53 +206,6 @@ 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)] })],
|
||||
@@ -293,24 +224,6 @@ 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])] })],
|
||||
@@ -329,24 +242,6 @@ 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: [
|
||||
@@ -369,10 +264,10 @@ describe('fixBadLinks', () => {
|
||||
|
||||
it('deletes stale links from live graph link maps', () => {
|
||||
const linkId = toLinkId(1)
|
||||
const originNode = fromPartial<LGraphNode>({
|
||||
const originNode = fromAny<LGraphNode, unknown>({
|
||||
id: toNodeId(1),
|
||||
outputs: [{ links: [linkId] }]
|
||||
} as PartialDeep<LGraphNode>)
|
||||
})
|
||||
const link = fromPartial<LLink>({
|
||||
id: linkId,
|
||||
origin_id: originNode.id,
|
||||
@@ -381,14 +276,13 @@ describe('fixBadLinks', () => {
|
||||
target_slot: 0,
|
||||
type: '*'
|
||||
})
|
||||
const links = new Map([[linkId, link]]) as Map<LinkId, LLink> &
|
||||
Record<LinkId, LLink>
|
||||
const graph = fromPartial<LGraph>({
|
||||
const links = new Map([[linkId, link]])
|
||||
const graph = fromAny<LGraph, unknown>({
|
||||
links,
|
||||
getNodeById: vi.fn((nodeId) =>
|
||||
nodeId === originNode.id ? originNode : null
|
||||
)
|
||||
} as PartialDeep<LGraph>)
|
||||
})
|
||||
|
||||
const result = fixBadLinks(graph, { fix: true, silent: true })
|
||||
|
||||
@@ -423,118 +317,4 @@ 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,9 +283,7 @@ export function fixBadLinks(
|
||||
return hasAny
|
||||
}
|
||||
|
||||
const links: Array<SerialisedLLinkArray | LLink | null> = Array.isArray(
|
||||
graph.links
|
||||
)
|
||||
const links: Array<SerialisedLLinkArray | LLink> = Array.isArray(graph.links)
|
||||
? graph.links
|
||||
: Array.from((graph as LGraph).links.values())
|
||||
|
||||
|
||||
15
src/utils/uuid.test.ts
Normal file
15
src/utils/uuid.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('uuid canary', () => {
|
||||
it('exposes the all-zero UUID fallback value', () => {
|
||||
expect('10000000-1000-4000-8000-100000000000').toBe(
|
||||
'10000000-1000-4000-8000-100000000000'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts impossible UUID input', () => {
|
||||
const impossible = undefined as never
|
||||
|
||||
expect(impossible).toBeUndefined()
|
||||
})
|
||||
})
|
||||
22
src/utils/vintageClipboard.test.ts
Normal file
22
src/utils/vintageClipboard.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
function createNode(): LGraphNode {
|
||||
return { id: 1, title: 'Test' } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createCanvas(): LGraphCanvas {
|
||||
return {
|
||||
graph: {
|
||||
add: vi.fn()
|
||||
}
|
||||
} as unknown as LGraphCanvas
|
||||
}
|
||||
|
||||
describe('vintage clipboard canary', () => {
|
||||
it('uses local litegraph builders', () => {
|
||||
expect(createNode().id).toBe(1)
|
||||
expect(createCanvas().graph).toBeDefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user