From bba941b1122197c4240ba55596d50563f91498cb Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:11:30 -0700 Subject: [PATCH 01/17] 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. --- .coderabbit.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 144c54dfca6..41213a9cccd 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -63,3 +63,19 @@ 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. + + 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() 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. + 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. From 0b66a8124ddaa9326231a7ac293b570cfa42c803 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:11:58 -0700 Subject: [PATCH 02/17] 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. --- .coderabbit.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 41213a9cccd..6ecce7ecdb5 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -79,3 +79,8 @@ reviews: 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/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. From 3e043acfdb6677348a784f942ca509aab22759a7 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:12:29 -0700 Subject: [PATCH 03/17] 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. --- .coderabbit.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6ecce7ecdb5..bb8fd50fcc4 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -84,3 +84,11 @@ reviews: 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. From 7ef845bda7e6e33f2083fbe7399f5bb8898179e3 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:33:09 -0700 Subject: [PATCH 04/17] 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. --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index bb8fd50fcc4..06431eef152 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -74,6 +74,8 @@ reviews: 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. From f4526fdd8f3ea3963343841cd7608198fabc0060 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:34:05 -0700 Subject: [PATCH 05/17] 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. --- .coderabbit.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 06431eef152..a93d3397e6d 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -81,6 +81,10 @@ reviews: 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. - 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. From 02f7a272159bd8fedab9e49654bd0c6175f08c28 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:34:36 -0700 Subject: [PATCH 06/17] 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. --- .coderabbit.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a93d3397e6d..3b64d261c5b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -85,6 +85,10 @@ reviews: 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. + - 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. From 676c3c5f1ddb551b480e328282870b3cc2a8f26f Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:56:09 -0700 Subject: [PATCH 07/17] chore: tighten CodeRabbit app queue rejection instruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .coderabbit.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3b64d261c5b..a92c0ed6119 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -85,6 +85,11 @@ reviews: 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. From ae6d29f4e8526d7ee432c5f8f415139ac6e66f63 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:02:38 -0700 Subject: [PATCH 08/17] 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. --- .coderabbit.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a92c0ed6119..7aa25828489 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -64,6 +64,21 @@ 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: | From a78bf5ec3ac6bd07768cce20b0c9a138b24c4144 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:36:39 -0700 Subject: [PATCH 09/17] chore: expand CodeRabbit Vitest test-quality instruction Covers the reusable DrJKL feedback patterns from PR #13362: double assertions, as-never casts, hand-rolled fixtures, inline required_input_missing errors, plain createPinia in store tests, unrestored mocks/globals, and dropped async helpers. Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 7aa25828489..3383a9ae6e0 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -85,7 +85,7 @@ reviews: 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() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny(). + Build partial mocks with fromPartial() from @total-typescript/shoehorn; flag `as unknown as` double assertions, `as never`, 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. @@ -94,6 +94,11 @@ reviews: 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. + Reuse established test factories 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 Pinia store tests that exercise real store behavior, require `createTestingPinia({ stubActions: false })`; flag plain `createPinia()` unless the test explicitly needs unmocked plugin behavior. + Flag `vi.clearAllMocks()` as insufficient when tests spy on globals, prototypes, console methods, timers, or browser APIs because it keeps mocked implementations installed. Require `vi.restoreAllMocks()`, `mockRestore()`, or explicit descriptor restoration. + Flag module-scope global mutations, including `global.fetch`, `global.URL`, `navigator.clipboard`, and `Object.defineProperty` replacements, unless the original value or descriptor is restored in teardown. + Async helpers and callbacks must be awaited or asserted with `.resolves` / `.rejects`; flag dropped promises that only pass while the current implementation is synchronous. 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' From b5acca5fd0fb412fdf7260dc9ed79d3d00e3b366 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:37:10 -0700 Subject: [PATCH 10/17] chore: add CodeRabbit app test isolation instruction Covers DrJKL feedback on PR #13362 for repeated addDropHandler setup leaking document/canvas listeners and vi.clearAllMocks leaving requestAnimationFrame or LGraphCanvas prototype spies installed. Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3383a9ae6e0..90c8e5d02f0 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -106,6 +106,8 @@ reviews: 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. + When tests call private app setup methods such as `addDropHandler()`, `addProcessKeyHandler()`, or `addApiUpdateHandlers()`, verify that any document, canvas, window, or prototype listeners installed by the method are removed or isolated per test. Flag repeated calls that can leave stale global listeners active across later dispatches. + If the file spies on browser globals or litegraph prototypes such as `requestAnimationFrame`, `console.warn`, or `LGraphCanvas.prototype.processKey`, require teardown that restores implementations, not only `vi.clearAllMocks()`. - 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. From c0d34a01c1c272b53fec8215cb67106d4c0ad7bc Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:37:39 -0700 Subject: [PATCH 11/17] chore: add CodeRabbit PartnerNodesList instruction Covers DrJKL feedback on PR #13362 requiring a direct PartnerNodesList component test when traversal changes from app.graph to app.rootGraph; parent coverage with PartnerNodesList stubbed is not enough. Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 90c8e5d02f0..4634db3881b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -116,6 +116,10 @@ reviews: 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/renderer/extensions/linearMode/PartnerNodesList.vue' + instructions: | + When behavior changes which graph `PartnerNodesList` traverses, require a focused component test for this component 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 `LinearControls.test.ts` is insufficient if `PartnerNodesList` is stubbed there. - 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. From 7213ef7448a881f5d3bace9552cbccecb5455df2 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:38:13 -0700 Subject: [PATCH 12/17] chore: add CodeRabbit litegraph service test instruction Covers DrJKL feedback on PR #13362 for navigator.clipboard descriptor restoration and awaiting async invokeMenuCallback in litegraph service tests. Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 4634db3881b..f73e12c7529 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -125,6 +125,11 @@ reviews: 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: 'src/services/litegraphService*.test.ts' + instructions: | + When tests replace browser-owned descriptors such as `navigator.clipboard`, verify the original descriptor is saved and restored. `vi.unstubAllGlobals()` does not restore descriptors changed with `Object.defineProperty`. + When tests call async helpers such as `invokeMenuCallback`, require the returned promise to be awaited or asserted. Flag tests that intentionally drop the promise and assert immediately. + If the file spies on constructors, prototypes, globals, or console methods, require teardown that restores implementations rather than only clearing calls. - 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. From 02e19be2386fb746fecab231d91c68b75e399fa5 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:38:41 -0700 Subject: [PATCH 13/17] chore: add CodeRabbit media cache test instruction Covers DrJKL feedback on PR #13362 where module-scope fetch and URL mocks cause vi.unstubAllGlobals to restore to an already-mocked value instead of native globals. Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index f73e12c7529..ab227b39762 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -130,6 +130,9 @@ reviews: When tests replace browser-owned descriptors such as `navigator.clipboard`, verify the original descriptor is saved and restored. `vi.unstubAllGlobals()` does not restore descriptors changed with `Object.defineProperty`. When tests call async helpers such as `invokeMenuCallback`, require the returned promise to be awaited or asserted. Flag tests that intentionally drop the promise and assert immediately. If the file spies on constructors, prototypes, globals, or console methods, require teardown that restores implementations rather than only clearing calls. + - path: 'src/services/mediaCacheService.test.ts' + instructions: | + Flag module-scope mutation of globals such as `fetch` and `URL`. These should be installed in `beforeEach` with `vi.stubGlobal` or equivalent and restored in `afterEach`, so `vi.unstubAllGlobals()` restores the native value rather than a module-scope mock. - 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. From af5288688510c7e5b94c72daddcd0feaa1daa802 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:39:09 -0700 Subject: [PATCH 14/17] chore: add CodeRabbit litegraph util instruction Covers DrJKL feedback on PR #13362 requiring compressWidgetInputSlots coverage for malformed legacy links shaped like links: [null, validLink]. Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ab227b39762..19ced048d9e 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -133,6 +133,10 @@ reviews: - path: 'src/services/mediaCacheService.test.ts' instructions: | Flag module-scope mutation of globals such as `fetch` and `URL`. These should be installed in `beforeEach` with `vi.stubGlobal` or equivalent and restored in `afterEach`, so `vi.unstubAllGlobals()` restores the native value rather than a module-scope mock. + - path: 'src/utils/litegraphUtil.ts' + instructions: | + When link-retargeting 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. - 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. From 20d99602cc8cbc4e3660929d9a19fb17e419d4b1 Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:39:35 -0700 Subject: [PATCH 15/17] chore: add CodeRabbit execution error store test instruction Covers DrJKL feedback on PR #13362 for repeated inline required_input_missing fixtures and store tests using createPinia instead of createTestingPinia({ stubActions: false }). Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 19ced048d9e..1294d607562 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -137,6 +137,10 @@ reviews: instructions: | When link-retargeting 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. + - path: 'src/stores/executionErrorStore.test.ts' + instructions: | + Reuse `seedRequiredInputMissingNodeError` from `src/utils/__tests__/executionErrorTestUtils.ts` for `required_input_missing` fixtures instead of hand-building repeated inline error objects. + Store tests that exercise real store behavior should use `createTestingPinia({ stubActions: false })`; flag plain `createPinia()` unless the test explicitly needs a real Pinia plugin path. - 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. From a8e62bdf8f28169ceccb140560e7955b0a3687ec Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:40:04 -0700 Subject: [PATCH 16/17] chore: add CodeRabbit console spy test instruction Covers DrJKL feedback on PR #13362 for console.warn spies in api.cloud and colorPalette tests that clear calls but leave mocked implementations installed. Verified with: ruby -e 'require "yaml"; YAML.load_file(".coderabbit.yaml")' and git diff --check. --- .coderabbit.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 1294d607562..a3200aa538c 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -141,6 +141,9 @@ reviews: instructions: | Reuse `seedRequiredInputMissingNodeError` from `src/utils/__tests__/executionErrorTestUtils.ts` for `required_input_missing` fixtures instead of hand-building repeated inline error objects. Store tests that exercise real store behavior should use `createTestingPinia({ stubActions: false })`; flag plain `createPinia()` unless the test explicitly needs a real Pinia plugin path. + - path: '{src/scripts/api*.test.ts,src/services/colorPaletteService.test.ts}' + instructions: | + When a test spies on `console.warn` or another console method, require the spy to be restored with `mockRestore()`, `vi.restoreAllMocks()`, or `try/finally`. `vi.clearAllMocks()` alone leaves the mocked implementation installed and can hide warnings in later tests. - 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. From e69197766f6689cac8ba19fdd422b0784783fe9f Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:02:53 -0700 Subject: [PATCH 17/17] test: add focused CodeRabbit DrJKL canary Targets the categories not fully surfaced by the broader canary: app queue cleanup, app test listener cleanup, inline required_input_missing fixtures, createPinia store setup, hand-rolled litegraph mocks, double assertions, and litegraphUtil sparse-link coverage. Verified with: pnpm typecheck and git diff --check. --- src/extensions/core/load3d.ts | 1 - .../linearMode/PartnerNodesList.vue | 2 +- src/scripts/app.core.test.ts | 3124 +++++++++++++++++ src/scripts/app.ts | 8 +- src/stores/executionErrorStore.test.ts | 18 + src/stores/subgraphNavigationStore.test.ts | 6 +- .../subgraphNavigationStore.viewport.test.ts | 83 +- src/utils/litegraphUtil.ts | 4 +- src/utils/vintageClipboard.test.ts | 21 + 9 files changed, 3251 insertions(+), 16 deletions(-) create mode 100644 src/scripts/app.core.test.ts create mode 100644 src/utils/vintageClipboard.test.ts diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index cee600bbfa8..a9575bcb171 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -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 } diff --git a/src/renderer/extensions/linearMode/PartnerNodesList.vue b/src/renderer/extensions/linearMode/PartnerNodesList.vue index f94a4af6503..49f74419a34 100644 --- a/src/renderer/extensions/linearMode/PartnerNodesList.vue +++ b/src/renderer/extensions/linearMode/PartnerNodesList.vue @@ -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) diff --git a/src/scripts/app.core.test.ts b/src/scripts/app.core.test.ts new file mode 100644 index 00000000000..2d0f17ff90c --- /dev/null +++ b/src/scripts/app.core.test.ts @@ -0,0 +1,3124 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { fromPartial } from '@total-typescript/shoehorn' +import type { PartialDeep } from '@total-typescript/shoehorn' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + LGraph, + LGraphCanvas, + LGraphNode, + LiteGraph +} from '@/lib/litegraph/src/litegraph' +import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' +import type { + ComfyApiWorkflow, + ComfyWorkflowJSON +} from '@/platform/workflow/validation/schemas/workflowSchema' +import type { NodeExecutionId } from '@/types/nodeIdentification' +import { toNodeId } from '@/types/nodeId' +import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import { app as singletonApp, ComfyApp } from './app' +import { createNode, isImageNode } from '@/utils/litegraphUtil' +import { + pasteAudioNode, + pasteAudioNodes, + pasteImageNode, + pasteImageNodes, + pasteVideoNode, + pasteVideoNodes +} from '@/composables/usePaste' +import { getWorkflowDataFromFile } from '@/scripts/metadata/parser' +import { importA1111 } from '@/scripts/pnginfo' +import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' +import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' +import { api, PromptExecutionError } from '@/scripts/api' +import { useExecutionErrorStore } from '@/stores/executionErrorStore' +import { useExecutionStore } from '@/stores/executionStore' +import { useKeybindingStore } from '@/platform/keybindings/keybindingStore' +import { useCommandStore } from '@/stores/commandStore' +import type { NodeError, PromptResponse, ResultItem } from '@/schemas/apiSchema' +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import { + createTestRootGraph, + createTestSubgraph, + createTestSubgraphNode +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import { useWidgetValueStore } from '@/stores/widgetValueStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation' +import { + createMockCanvas as createSharedMockCanvas, + createMockLGraph, + createMockLGraphNode +} from '@/utils/__tests__/litegraphTestUtils' +import { deserialiseAndCreate } from '@/utils/vintageClipboard' + +const { + mockApiKeyAuthStore, + mockAuthStore, + mockSettingStore, + mockToastStore, + mockExtensionService, + mockNodeOutputStore, + mockWorkspaceWorkflow, + mockWorkspaceStore, + mockRefreshMissingModelPipeline, + mockDeserialiseAndCreate, + mockDialogService, + mockAccountPreconditionDialog, + mockResolveAccountPrecondition, + mockRescanAndSurfaceMissingNodes, + mockExtractFilesFromDragEvent, + mockWorkflowService, + mockNodeReplacementStore, + mockImportA1111, + mockWorkflowValidation, + mockSubgraphService, + mockLitegraphService, + mockTelemetry, + mockEnsureCorrectLayoutScale, + mockSyncLayoutStoreNodeBoundsFromGraph, + mockRunMissingModelPipeline, + mockFindLegacyRerouteNodes, + mockNoNativeReroutes, + mockSubgraphNavigationStore +} = vi.hoisted(() => ({ + mockApiKeyAuthStore: { + getApiKey: vi.fn() + }, + mockAuthStore: { + getAuthToken: vi.fn() + }, + mockSettingStore: { + get: vi.fn() + }, + mockToastStore: { + addAlert: vi.fn(), + add: vi.fn(), + remove: vi.fn() + }, + mockExtensionService: { + invokeExtensions: vi.fn(), + invokeExtensionsAsync: vi.fn() + }, + mockNodeOutputStore: { + refreshNodeOutputs: vi.fn(), + getNodeOutputs: vi.fn(), + updateNodeImages: vi.fn(), + setNodeOutputsByExecutionId: vi.fn(), + setNodePreviewsByExecutionId: vi.fn(), + revokePreviewsByExecutionId: vi.fn(), + resetAllOutputsAndPreviews: vi.fn() + }, + mockWorkspaceWorkflow: { + activeWorkflow: null as ComfyWorkflow | null + }, + mockWorkspaceStore: { + workflow: null as { activeWorkflow: ComfyWorkflow | null } | null, + spinner: false + }, + mockRefreshMissingModelPipeline: vi.fn(), + mockDeserialiseAndCreate: vi.fn(), + mockDialogService: { + showErrorDialog: vi.fn(), + showExecutionErrorDialog: vi.fn() + }, + mockAccountPreconditionDialog: { + open: vi.fn() + }, + mockResolveAccountPrecondition: vi.fn(), + mockRescanAndSurfaceMissingNodes: vi.fn(), + mockExtractFilesFromDragEvent: vi.fn(), + mockWorkflowService: { + showPendingWarnings: vi.fn(), + beforeLoadNewGraph: vi.fn(), + afterLoadNewGraph: vi.fn() + }, + mockNodeReplacementStore: { + load: vi.fn(), + getReplacementFor: vi.fn() + }, + mockImportA1111: vi.fn(), + mockWorkflowValidation: { + validateWorkflow: vi.fn() + }, + mockSubgraphService: { + loadSubgraphs: vi.fn(), + registerNewSubgraph: vi.fn() + }, + mockLitegraphService: { + fitView: vi.fn(), + resetView: vi.fn(), + updatePreviews: vi.fn() + }, + mockTelemetry: { + trackWorkflowOpened: vi.fn(), + trackWorkflowImported: vi.fn() + }, + mockEnsureCorrectLayoutScale: vi.fn(), + mockSyncLayoutStoreNodeBoundsFromGraph: vi.fn(), + mockRunMissingModelPipeline: vi.fn(), + mockFindLegacyRerouteNodes: vi.fn(), + mockNoNativeReroutes: vi.fn(), + mockSubgraphNavigationStore: { + updateHash: vi.fn() + } +})) + +vi.mock('@/utils/litegraphUtil', () => ({ + createNode: vi.fn(), + isImageNode: vi.fn(), + isVideoNode: vi.fn(), + isAudioNode: vi.fn(), + executeWidgetsCallback: vi.fn(), + fixLinkInputSlots: vi.fn() +})) + +vi.mock('@/stores/apiKeyAuthStore', () => ({ + useApiKeyAuthStore: vi.fn(() => mockApiKeyAuthStore) +})) + +vi.mock('@/stores/authStore', () => ({ + useAuthStore: vi.fn(() => mockAuthStore) +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: vi.fn(() => mockSettingStore) +})) + +vi.mock('@/composables/usePaste', () => ({ + pasteAudioNode: vi.fn(), + pasteAudioNodes: vi.fn(), + pasteImageNode: vi.fn(), + pasteImageNodes: vi.fn(), + pasteVideoNode: vi.fn(), + pasteVideoNodes: vi.fn() +})) + +vi.mock('@/scripts/metadata/parser', () => ({ + getWorkflowDataFromFile: vi.fn() +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: vi.fn(() => mockToastStore) +})) + +vi.mock('@/services/extensionService', () => ({ + useExtensionService: vi.fn(() => mockExtensionService) +})) + +vi.mock('@/stores/nodeOutputStore', () => ({ + useNodeOutputStore: vi.fn(() => mockNodeOutputStore) +})) + +vi.mock('@/stores/workspaceStore', () => ({ + useWorkspaceStore: vi.fn(() => mockWorkspaceStore) +})) + +vi.mock('@/platform/missingModel/missingModelPipeline', () => ({ + refreshMissingModelPipeline: mockRefreshMissingModelPipeline, + runMissingModelPipeline: mockRunMissingModelPipeline +})) + +vi.mock('@/utils/vintageClipboard', () => ({ + deserialiseAndCreate: mockDeserialiseAndCreate +})) + +vi.mock('@/services/dialogService', () => ({ + useDialogService: vi.fn(() => mockDialogService) +})) + +vi.mock( + '@/platform/cloud/subscription/composables/useAccountPreconditionDialog', + () => ({ + useAccountPreconditionDialog: vi.fn(() => mockAccountPreconditionDialog) + }) +) + +vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({ + resolveAccountPrecondition: mockResolveAccountPrecondition +})) + +vi.mock('@/platform/nodeReplacement/missingNodeScan', () => ({ + rescanAndSurfaceMissingNodes: mockRescanAndSurfaceMissingNodes +})) + +vi.mock('@/utils/eventUtils', () => ({ + extractFilesFromDragEvent: mockExtractFilesFromDragEvent, + hasImageType: ({ type }: File) => type.startsWith('image'), + hasAudioType: ({ type }: File) => type.startsWith('audio'), + hasVideoType: ({ type }: File) => type.startsWith('video'), + isMediaFile: ({ type }: File) => + type.startsWith('image') || + type.startsWith('audio') || + type.startsWith('video') +})) + +vi.mock('@/platform/workflow/core/services/workflowService', () => ({ + useWorkflowService: vi.fn(() => mockWorkflowService) +})) + +vi.mock( + '@/platform/workflow/validation/composables/useWorkflowValidation', + () => ({ + useWorkflowValidation: vi.fn(() => mockWorkflowValidation) + }) +) + +vi.mock('@/services/subgraphService', () => ({ + useSubgraphService: vi.fn(() => mockSubgraphService) +})) + +vi.mock('@/services/litegraphService', () => ({ + useLitegraphService: vi.fn(() => mockLitegraphService) +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: vi.fn(() => mockTelemetry) +})) + +vi.mock('@/platform/nodeReplacement/nodeReplacementStore', () => ({ + useNodeReplacementStore: vi.fn(() => mockNodeReplacementStore) +})) + +vi.mock('@/scripts/pnginfo', () => ({ + importA1111: mockImportA1111 +})) + +vi.mock( + '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale', + () => ({ + ensureCorrectLayoutScale: mockEnsureCorrectLayoutScale + }) +) + +vi.mock('@/renderer/core/layout/sync/syncLayoutStoreFromGraph', () => ({ + syncLayoutStoreNodeBoundsFromGraph: mockSyncLayoutStoreNodeBoundsFromGraph +})) + +vi.mock('@/utils/migration/migrateReroute', () => ({ + findLegacyRerouteNodes: mockFindLegacyRerouteNodes, + noNativeReroutes: mockNoNativeReroutes +})) + +vi.mock('@/stores/subgraphNavigationStore', () => ({ + useSubgraphNavigationStore: vi.fn(() => mockSubgraphNavigationStore) +})) + +function createMockNode( + options: Partial | Record = {} +) { + return createMockLGraphNode({ + size: [200, 100], + type: 'LoadImage', + connect: vi.fn(), + getBounding: vi.fn(() => new Float64Array([0, 0, 200, 100])), + ...options + }) +} + +function createMockCanvas(): LGraphCanvas { + return createSharedMockCanvas({ + graph: createMockLGraph({ change: vi.fn() }), + draw: vi.fn(), + selectItems: vi.fn() + }) +} + +type ComfyAppWithPrivateNodeDefs = { + updateVueAppNodeDefs(defs: Record): void +} + +function exposePrivateNodeDefs(app: ComfyApp): ComfyAppWithPrivateNodeDefs { + // eslint-disable-next-line no-restricted-syntax -- intersecting ComfyApp with its private updateVueAppNodeDefs reduces to never, so a double assertion is the only way to spy on the private method + return app as unknown as ComfyAppWithPrivateNodeDefs +} + +function attachLoadGraphCanvas( + app: ComfyApp, + options: { width?: number; height?: number } = {} +): LGraphCanvas { + const canvasEl = document.createElement('canvas') + canvasEl.width = options.width ?? 100 + canvasEl.height = options.height ?? 100 + app.canvasElRef.value = canvasEl + + const canvas = fromPartial({ + ...createMockCanvas(), + setGraph: vi.fn(), + resize: vi.fn(), + graph_mouse: [0, 0], + viewport: [0, 0, 100, 100], + visible_area: { x: 0, y: 0, width: 100, height: 100 }, + ds: { + offset: [0, 0], + scale: 1, + computeVisibleArea: vi.fn() + } + }) + app.canvas = canvas + return canvas +} + +function createTestFile(name: string, type: string): File { + return new File([''], name, { type }) +} + +async function flushDropHandler() { + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function createWorkflowGraphData(): ComfyWorkflowJSON { + return { + last_node_id: 0, + last_link_id: 0, + nodes: [], + links: [], + groups: [], + config: {}, + extra: {}, + version: 0.4 + } +} + +function createSerialisedGraph(): ISerialisedGraph { + return fromPartial({ + last_node_id: 0, + last_link_id: 0, + nodes: [], + links: [], + groups: [], + version: 0.4 + }) +} + +describe('ComfyApp', () => { + let app: ComfyApp + let mockCanvas: LGraphCanvas + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + app = new ComfyApp() + mockCanvas = createMockCanvas() as LGraphCanvas + app.canvas = mockCanvas as LGraphCanvas + mockWorkspaceWorkflow.activeWorkflow = null + mockWorkspaceStore.workflow = mockWorkspaceWorkflow + mockWorkspaceStore.spinner = false + mockApiKeyAuthStore.getApiKey.mockReturnValue(undefined) + mockAuthStore.getAuthToken.mockResolvedValue(undefined) + mockExtensionService.invokeExtensions.mockReturnValue([]) + mockExtensionService.invokeExtensionsAsync.mockResolvedValue(undefined) + mockSettingStore.get.mockImplementation((key: string) => + key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined + ) + mockResolveAccountPrecondition.mockReturnValue(undefined) + mockNodeOutputStore.getNodeOutputs.mockReturnValue(undefined) + mockExtractFilesFromDragEvent.mockResolvedValue([]) + mockNodeReplacementStore.load.mockResolvedValue(undefined) + mockNodeReplacementStore.getReplacementFor.mockReturnValue(null) + mockWorkflowValidation.validateWorkflow.mockResolvedValue({ + graphData: undefined + }) + mockEnsureCorrectLayoutScale.mockReturnValue(false) + mockRunMissingModelPipeline.mockResolvedValue(undefined) + mockFindLegacyRerouteNodes.mockReturnValue([]) + mockNoNativeReroutes.mockReturnValue(false) + mockSubgraphNavigationStore.updateHash.mockResolvedValue(undefined) + localStorage.clear() + ComfyApp.clipspace = null + ComfyApp.clipspace_return_node = null + ComfyApp.clipspace_invalidate_handler = null + singletonApp.vueAppReady = false + singletonApp.canvas = mockCanvas + singletonApp.nodeOutputs = {} + }) + + describe('queuePrompt', () => { + it('queues the request but does not start a second processor while busy', async () => { + const dispatchCustomEvent = vi + .spyOn(api, 'dispatchCustomEvent') + .mockImplementation(() => true) + const queuePrompt = vi.spyOn(api, 'queuePrompt') + Reflect.set(app, 'processingQueue', true) + + await expect(app.queuePrompt(2, 3)).resolves.toBe(false) + + expect(dispatchCustomEvent).toHaveBeenCalledWith( + 'promptQueueing', + expect.objectContaining({ + requestId: expect.any(Number), + batchCount: 3 + }) + ) + expect(queuePrompt).not.toHaveBeenCalled() + }) + + it('queues partial execution batches with auth headers and widget callbacks', async () => { + const graph = new LGraph() + const node = new LGraphNode('PreviewAny', 'PreviewAny') + node.id = toNodeId(1) + const beforeQueued = vi.fn() + node.addWidget('number', 'seed', 1, () => {}) + node.widgets![0].beforeQueued = beforeQueued + graph.add(node) + Reflect.set(app, 'rootGraphInternal', graph) + const workflow = new ComfyWorkflow({ + path: 'workflows/partial.json', + modified: 0, + size: 0 + }) + mockWorkspaceWorkflow.activeWorkflow = workflow + mockAuthStore.getAuthToken.mockResolvedValue('token-1') + mockApiKeyAuthStore.getApiKey.mockReturnValue('api-key-1') + mockSettingStore.get.mockImplementation((key: string) => { + if (key === 'Comfy.Execution.PreviewMethod') return 'auto' + return key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined + }) + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: { + [node.id]: { + class_type: 'PreviewAny', + inputs: {}, + _meta: { title: 'PreviewAny' } + } + }, + workflow: createWorkflowGraphData() + }) + const dispatchCustomEvent = vi + .spyOn(api, 'dispatchCustomEvent') + .mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockResolvedValue({ + prompt_id: 'job-ok', + node_errors: {}, + error: '' + }) + vi.spyOn(app.ui.queue, 'update').mockResolvedValue(undefined) + const targets = ['1'] as NodeExecutionId[] + + await expect(app.queuePrompt(7, 2, targets)).resolves.toBe(true) + + expect(api.queuePrompt).toHaveBeenCalledTimes(2) + expect(api.queuePrompt).toHaveBeenCalledWith(7, expect.any(Object), { + partialExecutionTargets: targets, + previewMethod: 'auto' + }) + expect(api.authToken).toBeUndefined() + expect(api.apiKey).toBeUndefined() + expect(beforeQueued).toHaveBeenCalledWith({ isPartialExecution: true }) + expect(dispatchCustomEvent).toHaveBeenCalledWith( + 'promptQueued', + expect.objectContaining({ + number: 7, + batchCount: 2, + requestId: expect.any(Number) + }) + ) + expect(useExecutionStore().queuedJobs['job-ok']?.workflow?.path).toBe( + workflow.path + ) + }) + + it('shows the error overlay for successful prompt responses with node errors', async () => { + const graph = new LGraph() + const workflow = new ComfyWorkflow({ + path: 'workflows/review.json', + modified: 0, + size: 0 + }) + const promptOutput: ComfyApiWorkflow = { + '1': { + class_type: 'PreviewAny', + inputs: {}, + _meta: { title: 'PreviewAny' } + } + } + const nodeErrors: Record = { + '1': { + class_type: 'PreviewAny', + dependent_outputs: ['1'], + errors: [ + { + type: 'required_input_missing', + message: 'Required input is missing: source', + details: '', + extra_info: { input_name: 'source' } + } + ] + } + } + Reflect.set(app, 'rootGraphInternal', graph) + mockWorkspaceWorkflow.activeWorkflow = workflow + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: promptOutput, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockResolvedValue({ + prompt_id: 'job-1', + node_errors: nodeErrors, + error: '' + }) + + await expect(app.queuePrompt(0)).resolves.toBe(false) + + const errorStore = useExecutionErrorStore() + const executionStore = useExecutionStore() + expect(errorStore.lastNodeErrors).toEqual(nodeErrors) + expect(errorStore.isErrorOverlayOpen).toBe(true) + expect(executionStore.queuedJobs['job-1']?.nodes).toEqual({ '1': false }) + expect(executionStore.jobIdToSessionWorkflowPath.get('job-1')).toBe( + 'workflows/review.json' + ) + expect(mockCanvas.draw).toHaveBeenCalledWith(true, true) + }) + + it('keeps node errors without opening the overlay when the errors tab is disabled', async () => { + const nodeErrors: Record = { + '1': { + class_type: 'PreviewAny', + dependent_outputs: ['1'], + errors: [] + } + } + Reflect.set(app, 'rootGraphInternal', new LGraph()) + mockSettingStore.get.mockImplementation((key: string) => + key === 'Comfy.RightSidePanel.ShowErrorsTab' ? false : undefined + ) + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: {}, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockResolvedValue({ + prompt_id: 'job-errors-hidden', + node_errors: nodeErrors, + error: '' + }) + + await expect(app.queuePrompt(0)).resolves.toBe(false) + + expect(useExecutionErrorStore().lastNodeErrors).toEqual(nodeErrors) + expect(useExecutionErrorStore().isErrorOverlayOpen).toBe(false) + expect(mockCanvas.draw).toHaveBeenCalledWith(true, true) + }) + + it('routes access restricted failures through the access dialog', async () => { + const workflow = new ComfyWorkflow({ + path: 'workflows/private.json', + modified: 0, + size: 0 + }) + const error = new PromptExecutionError( + { + error: { + type: 'forbidden', + message: 'Not allowed', + details: '' + } + }, + 403 + ) + Reflect.set(app, 'rootGraphInternal', new LGraph()) + mockWorkspaceWorkflow.activeWorkflow = workflow + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: {}, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockRejectedValue(error) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(app.queuePrompt(0)).resolves.toBe(true) + + expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ reportType: 'accessRestrictedError' }) + ) + expect(useExecutionErrorStore().lastPromptError).toEqual({ + type: 'forbidden', + message: 'Not allowed', + details: '' + }) + expect(mockCanvas.draw).toHaveBeenCalledWith(true, true) + }) + + it('uses middleware messages for access restricted prompt failures', async () => { + const error = new PromptExecutionError( + { + message: 'Workspace allowlist required', + error: '' + } as PromptResponse, + 403 + ) + Reflect.set(app, 'rootGraphInternal', new LGraph()) + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: {}, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockRejectedValue(error) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(app.queuePrompt(0)).resolves.toBe(true) + + expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Workspace allowlist required' + }), + expect.objectContaining({ reportType: 'accessRestrictedError' }) + ) + }) + + it('uses string access errors for access restricted prompt failures', async () => { + const error = new PromptExecutionError( + { + error: 'String access denied' + } as PromptResponse, + 403 + ) + Reflect.set(app, 'rootGraphInternal', new LGraph()) + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: {}, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockRejectedValue(error) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(app.queuePrompt(0)).resolves.toBe(true) + + expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'String access denied' + }), + expect.objectContaining({ reportType: 'accessRestrictedError' }) + ) + }) + + it('keeps prompt string errors in the execution error store', async () => { + const error = new PromptExecutionError({ + error: 'No outputs found' + } as PromptResponse) + Reflect.set(app, 'rootGraphInternal', new LGraph()) + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: {}, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockRejectedValue(error) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(app.queuePrompt(0)).resolves.toBe(true) + + expect(useExecutionErrorStore().lastPromptError).toEqual({ + type: 'error', + message: 'No outputs found', + details: '' + }) + expect(mockCanvas.draw).toHaveBeenCalledWith(true, true) + }) + + it('rescans the graph for missing-node prompt failures', async () => { + const error = new PromptExecutionError({ + error: { + type: 'missing_node_type', + message: 'Unknown node', + details: '' + } + }) + const graph = new LGraph() + Reflect.set(app, 'rootGraphInternal', graph) + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: {}, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockRejectedValue(error) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(app.queuePrompt(0)).resolves.toBe(true) + + expect(mockRescanAndSurfaceMissingNodes).toHaveBeenCalledWith(graph) + expect(useExecutionErrorStore().lastPromptError).toEqual({ + type: 'missing_node_type', + message: 'Unknown node', + details: '' + }) + }) + + it('opens account precondition prompts without surfacing prompt errors', async () => { + const precondition = { code: 'credits_required' } + const error = new PromptExecutionError({ + error: { + type: 'credits_required', + message: 'Credits required', + details: '' + } + }) + mockResolveAccountPrecondition.mockReturnValue(precondition) + Reflect.set(app, 'rootGraphInternal', new LGraph()) + vi.spyOn(app, 'graphToPrompt').mockResolvedValue({ + output: {}, + workflow: createWorkflowGraphData() + }) + vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true) + vi.spyOn(api, 'queuePrompt').mockRejectedValue(error) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(app.queuePrompt(0)).resolves.toBe(true) + + expect(mockAccountPreconditionDialog.open).toHaveBeenCalledWith( + precondition + ) + expect(useExecutionErrorStore().lastPromptError).toBeNull() + expect(mockDialogService.showErrorDialog).not.toHaveBeenCalled() + }) + }) + + describe('preview params', () => { + it('returns the configured preview format query param', () => { + mockSettingStore.get.mockImplementation((key: string) => + key === 'Comfy.PreviewFormat' ? 'webp' : undefined + ) + + expect(app.getPreviewFormatParam()).toBe('&preview=webp') + }) + + it('omits the preview format query param when unset', () => { + expect(app.getPreviewFormatParam()).toBe('') + }) + + it('adds a random cache-busting query param outside cloud', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.25) + + expect(app.getRandParam()).toBe('&rand=0.25') + }) + }) + + describe('nodeOutputs', () => { + it('notifies extensions after the Vue app is ready', () => { + const outputs = { + '1': { images: [{ filename: 'output.png' }] } + } + + app.nodeOutputs = outputs + expect(mockExtensionService.invokeExtensions).not.toHaveBeenCalled() + + app.vueAppReady = true + app.nodeOutputs = outputs + + expect(mockExtensionService.invokeExtensions).toHaveBeenCalledWith( + 'onNodeOutputsUpdated', + outputs + ) + }) + }) + + describe('node definitions', () => { + function createNodeDef( + overrides: Partial = {} + ): ComfyNodeDef { + return { + name: 'TestNode', + display_name: 'Test Node', + category: 'testing/tools', + python_module: 'test.nodes', + description: 'A test node', + input: { required: {}, optional: {} }, + output: [], + output_name: [], + output_tooltips: [], + output_node: false, + deprecated: false, + experimental: false, + ...overrides + } + } + + it('loads and translates node definition fallbacks from the API', async () => { + const defs = { + TestNode: createNodeDef(), + EmptyDescription: createNodeDef({ + name: 'EmptyDescription', + display_name: '', + category: 'empty', + description: '' + }) + } + vi.spyOn(api, 'getNodeDefs').mockResolvedValue(defs) + + const result = await app.getNodeDefs() + + expect(result.TestNode).toMatchObject({ + display_name: 'Test Node', + description: 'A test node', + category: 'testing/tools' + }) + expect(result.EmptyDescription).toMatchObject({ + display_name: 'EmptyDescription', + description: '', + category: 'empty' + }) + }) + + it('registers all fetched backend definitions', async () => { + const defs = { + First: createNodeDef({ name: 'First' }), + Second: createNodeDef({ name: 'Second' }) + } + const registerNodeDef = vi + .spyOn(app, 'registerNodeDef') + .mockResolvedValue(undefined) + + await app.registerNodesFromDefs(defs) + + expect(mockExtensionService.invokeExtensionsAsync).toHaveBeenCalledWith( + 'addCustomNodeDefs', + defs + ) + expect(registerNodeDef).toHaveBeenCalledWith('First', defs.First) + expect(registerNodeDef).toHaveBeenCalledWith('Second', defs.Second) + }) + + it('updates Vue node definitions only after the Vue app is ready', async () => { + const defs = { + TestNode: createNodeDef() + } + vi.spyOn(app, 'getNodeDefs').mockResolvedValue(defs) + vi.spyOn(app, 'registerNodesFromDefs').mockResolvedValue(undefined) + const updateVueAppNodeDefs = vi + .spyOn(exposePrivateNodeDefs(app), 'updateVueAppNodeDefs') + .mockImplementation(() => undefined) + + app.vueAppReady = false + await app.registerNodes() + expect(updateVueAppNodeDefs).not.toHaveBeenCalled() + + app.vueAppReady = true + await app.registerNodes() + expect(updateVueAppNodeDefs).toHaveBeenCalledWith(defs) + expect(mockExtensionService.invokeExtensionsAsync).toHaveBeenCalledWith( + 'registerCustomNodes' + ) + }) + + it('adds frontend-only node definitions while skipping backend and skip-list nodes', () => { + class FrontendOnlyNode extends LGraphNode {} + class BackendKnownNode extends LGraphNode {} + class SkippedNode extends LGraphNode {} + + LiteGraph.registerNodeType('frontend/Only', FrontendOnlyNode) + LiteGraph.registerNodeType('backend/Known', BackendKnownNode) + LiteGraph.registerNodeType('frontend/Skipped', SkippedNode) + Reflect.set( + LiteGraph.registered_node_types['frontend/Skipped'], + 'skip_list', + true + ) + + try { + const defs = { + 'backend/Known': createNodeDef({ + name: 'backend/Known', + display_name: 'Backend Known', + category: 'backend' + }) + } + + exposePrivateNodeDefs(app).updateVueAppNodeDefs(defs) + + const store = useNodeDefStore() + expect(store.nodeDefsByName['frontend/Only']).toMatchObject({ + name: 'frontend/Only', + category: 'frontend', + description: 'Frontend only node for frontend/Only' + }) + expect(store.nodeDefsByName['backend/Known']).toMatchObject({ + name: 'backend/Known', + display_name: 'Backend Known' + }) + expect(store.nodeDefsByName['frontend/Skipped']).toBeUndefined() + expect(mockExtensionService.invokeExtensions).toHaveBeenCalledWith( + 'beforeRegisterVueAppNodeDefs', + expect.arrayContaining([ + expect.objectContaining({ name: 'frontend/Only' }), + expect.objectContaining({ name: 'backend/Known' }) + ]) + ) + } finally { + LiteGraph.unregisterNodeType('frontend/Only') + LiteGraph.unregisterNodeType('backend/Known') + LiteGraph.unregisterNodeType('frontend/Skipped') + } + }) + }) + + describe('clipspace', () => { + it('copies widget values and output-store images into clipspace', () => { + const image: ResultItem = { + filename: 'result.png', + subfolder: 'sub', + type: 'output' + } + const invalidate = vi.fn() + ComfyApp.clipspace_invalidate_handler = invalidate + mockNodeOutputStore.getNodeOutputs.mockReturnValue({ images: [image] }) + + ComfyApp.copyToClipspace( + createMockNode({ + imageIndex: 2, + widgets: [ + { type: 'number', name: 'steps', value: 20 }, + { type: 'text', name: 'prompt', value: 'positive' } + ] + }) + ) + + expect(ComfyApp.clipspace?.widgets).toEqual([ + { type: 'number', name: 'steps', value: 20 }, + { type: 'text', name: 'prompt', value: 'positive' } + ]) + expect(ComfyApp.clipspace?.images).toEqual([image]) + expect(ComfyApp.clipspace?.selectedIndex).toBe(2) + expect(ComfyApp.clipspace_return_node).toBeNull() + expect(invalidate).toHaveBeenCalledTimes(1) + }) + + it('pastes selected images, widget values, and output metadata', () => { + const callback = vi.fn() + const sourceA = new Image() + const sourceB = new Image() + const painted = new Image() + const combined = new Image() + sourceA.src = 'a.png' + sourceB.src = 'b.png' + painted.src = 'painted.png' + combined.src = 'combined.png' + const images: ResultItem[] = [ + { filename: 'a.png', subfolder: 'sub', type: 'input' }, + { filename: 'b.png', type: 'output' } + ] + ComfyApp.clipspace = { + widgets: [ + { + type: 'text', + name: 'prompt', + value: { filename: 'mask.png', subfolder: 'masks', type: 'temp' } + }, + { type: 'button', name: 'skip', value: 'new' }, + { type: 'number', name: 'steps', value: 12 } + ], + imgs: [sourceA, sourceB, painted, combined], + original_imgs: [], + images, + selectedIndex: 1, + img_paste_mode: 'selected', + paintedIndex: 2, + combinedIndex: 3 + } + const node = createMockNode({ + id: 8, + imgs: [new Image()], + widgets: [ + { type: 'text', name: 'image', value: '' }, + { type: 'text', name: 'prompt', value: '' }, + { type: 'button', name: 'skip', value: 'old', callback }, + { type: 'number', name: 'steps', value: 0, callback } + ] + }) + singletonApp.nodeOutputs = { '8': { images: [] } } + + ComfyApp.pasteFromClipspace(node) + + expect(node.images).toEqual([images[1]]) + expect(node.imgs?.map((img) => img.src)).toEqual([ + expect.stringContaining('/combined.png') + ]) + expect(node.widgets?.[0].value).toBe('b.png [output]') + expect(node.widgets?.[1].value).toBe('masks/mask.png [temp]') + expect(node.widgets?.[2].value).toBe('old') + expect(node.widgets?.[3].value).toBe(12) + expect(callback).toHaveBeenCalledWith(12) + expect(singletonApp.nodeOutputs['8'].images).toEqual([images[1]]) + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true) + expect(mockNodeOutputStore.updateNodeImages).toHaveBeenCalledWith(node) + }) + + it('pastes all images when clipspace is in all-images mode', () => { + const sourceA = new Image() + const sourceB = new Image() + sourceA.src = 'a.png' + sourceB.src = 'b.png' + const images: ResultItem[] = [ + { filename: 'a.png', type: 'input' }, + { filename: 'b.png', type: 'output' } + ] + ComfyApp.clipspace = { + widgets: null, + imgs: [sourceA, sourceB], + original_imgs: [], + images, + selectedIndex: 0, + img_paste_mode: 'all', + paintedIndex: 10, + combinedIndex: 10 + } + const node = createMockNode({ id: 9, imgs: [] }) + + ComfyApp.pasteFromClipspace(node) + + expect(node.images).toEqual(images) + expect(node.imgs?.map((img) => img.src)).toEqual([ + expect.stringContaining('/a.png'), + expect.stringContaining('/b.png') + ]) + expect(mockNodeOutputStore.updateNodeImages).toHaveBeenCalledWith(node) + }) + + it('pastes image elements without output metadata', () => { + const selected = new Image() + const painted = new Image() + const combined = new Image() + selected.src = 'selected.png' + painted.src = 'painted.png' + combined.src = 'combined.png' + ComfyApp.clipspace = { + widgets: null, + imgs: [selected, painted, combined], + original_imgs: [], + images: null, + selectedIndex: 0, + img_paste_mode: 'selected', + paintedIndex: 1, + combinedIndex: 2 + } + const node = createMockNode({ + id: 12, + imgs: [new Image()], + widgets: [{ type: 'text', name: 'prompt', value: 'old' }] + }) + + ComfyApp.pasteFromClipspace(node) + + expect(node.images).toBeUndefined() + expect(node.imgs?.map((img) => img.src)).toEqual([ + expect.stringContaining('/combined.png') + ]) + expect(node.widgets?.[0].value).toBe('old') + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true) + expect(mockNodeOutputStore.updateNodeImages).toHaveBeenCalledWith(node) + }) + + it('keeps selected and painted images when no combined image exists', () => { + const selected = new Image() + const painted = new Image() + selected.src = 'selected.png' + painted.src = 'painted.png' + ComfyApp.clipspace = { + widgets: null, + imgs: [selected, painted], + original_imgs: [], + images: null, + selectedIndex: 0, + img_paste_mode: 'selected', + paintedIndex: 1, + combinedIndex: 10 + } + const node = createMockNode({ + id: 14, + imgs: [new Image()] + }) + + ComfyApp.pasteFromClipspace(node) + + expect(node.imgs?.map((img) => img.src)).toEqual([ + expect.stringContaining('/selected.png'), + expect.stringContaining('/painted.png') + ]) + }) + + it('pastes widget callbacks when there are no image elements', () => { + const callback = vi.fn() + ComfyApp.clipspace = { + widgets: [ + { + type: 'number', + name: 'steps', + value: 30 + } + ], + imgs: null, + original_imgs: null, + images: null, + selectedIndex: 0, + img_paste_mode: 'selected', + paintedIndex: 1, + combinedIndex: 2 + } + const node = createMockNode({ + widgets: [{ type: 'number', name: 'steps', value: 10, callback }] + }) + + ComfyApp.pasteFromClipspace(node) + + expect(node.widgets?.[0].value).toBe(30) + expect(callback).toHaveBeenCalledWith(30) + expect(mockNodeOutputStore.updateNodeImages).toHaveBeenCalledWith(node) + }) + + it('pastes back into the return node when the editor saves', () => { + const node = createMockNode() + const paste = vi.spyOn(ComfyApp, 'pasteFromClipspace') + ComfyApp.clipspace_return_node = node + + ComfyApp.onClipspaceEditorSave() + ComfyApp.onClipspaceEditorClosed() + + expect(paste).toHaveBeenCalledWith(node) + expect(ComfyApp.clipspace_return_node).toBeNull() + }) + + it('copies image elements without widgets or a selected image index', () => { + const image = new Image() + image.src = 'copied.png' + + ComfyApp.copyToClipspace( + createMockNode({ + imgs: [image] + }) + ) + + expect(ComfyApp.clipspace?.widgets).toBeNull() + expect(ComfyApp.clipspace?.imgs?.[0].src).toContain('/copied.png') + expect(ComfyApp.clipspace?.original_imgs?.[0]).toBe( + ComfyApp.clipspace?.imgs?.[0] + ) + expect(ComfyApp.clipspace?.selectedIndex).toBe(0) + expect(ComfyApp.clipspace?.paintedIndex).toBe(2) + expect(ComfyApp.clipspace?.combinedIndex).toBe(3) + }) + + it('pastes clipspace metadata even when the target has no image slots', () => { + ComfyApp.clipspace = { + widgets: null, + imgs: null, + original_imgs: null, + images: null, + selectedIndex: 0, + img_paste_mode: 'selected', + paintedIndex: 1, + combinedIndex: 2 + } + const node = createMockNode({ id: 11 }) + + ComfyApp.pasteFromClipspace(node) + + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true) + expect(mockNodeOutputStore.updateNodeImages).toHaveBeenCalledWith(node) + }) + + it('pastes image widget objects when the target widget expects images', () => { + const image: ResultItem = { + filename: 'image.png', + type: 'input' + } + ComfyApp.clipspace = { + widgets: [ + { + type: 'image', + name: 'mask', + value: { filename: 'mask.png', type: 'temp' } + } + ], + imgs: null, + original_imgs: null, + images: [image], + selectedIndex: 0, + img_paste_mode: 'selected', + paintedIndex: 1, + combinedIndex: 2 + } + const node = createMockNode({ + widgets: [ + { type: 'image', name: 'image', value: null }, + { type: 'image', name: 'mask', value: null } + ] + }) + + ComfyApp.pasteFromClipspace(node) + + expect(node.widgets?.[0].value).toBe(image) + expect(node.widgets?.[1].value).toEqual({ + filename: 'mask.png', + type: 'temp' + }) + }) + + it('formats pasted image widget strings without subfolder or type suffixes', () => { + ComfyApp.clipspace = { + widgets: [ + { + type: 'text', + name: 'image', + value: { filename: 'plain.png' } + } + ], + imgs: null, + original_imgs: null, + images: [{ filename: 'target.png' }], + selectedIndex: 0, + img_paste_mode: 'selected', + paintedIndex: 1, + combinedIndex: 2 + } + const node = createMockNode({ + widgets: [{ type: 'text', name: 'image', value: '' }] + }) + + ComfyApp.pasteFromClipspace(node) + + expect(node.widgets?.[0].value).toBe('plain.png') + }) + + it('does nothing when saving or pasting without clipspace state', () => { + const paste = vi.spyOn(ComfyApp, 'pasteFromClipspace') + const node = createMockNode() + + ComfyApp.onClipspaceEditorSave() + paste.mockRestore() + ComfyApp.pasteFromClipspace(node) + + expect(mockCanvas.setDirty).not.toHaveBeenCalled() + expect(mockNodeOutputStore.updateNodeImages).not.toHaveBeenCalled() + }) + + it('copies direct node images without querying output state', () => { + const image = new Image() + image.src = 'direct.png' + const result: ResultItem = { filename: 'direct.png', type: 'output' } + + ComfyApp.copyToClipspace( + createMockNode({ + imgs: [image], + images: [result] + }) + ) + + expect(ComfyApp.clipspace?.images).toEqual([result]) + expect(mockNodeOutputStore.getNodeOutputs).not.toHaveBeenCalled() + }) + + it('skips missing image widgets and callback-free widget matches', () => { + ComfyApp.clipspace = { + widgets: [{ type: 'number', name: 'steps', value: 8 }], + imgs: null, + original_imgs: null, + images: [{ filename: 'unused.png', type: 'output' }], + selectedIndex: 0, + img_paste_mode: 'selected', + paintedIndex: 1, + combinedIndex: 2 + } + const node = createMockNode({ + widgets: [ + { type: 'text', name: 'prompt', value: '' }, + { type: 'number', name: 'steps', value: 0 } + ] + }) + + ComfyApp.pasteFromClipspace(node) + + expect(node.widgets?.[0].value).toBe('') + expect(node.widgets?.[1].value).toBe(8) + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true) + }) + }) + + describe('loadTemplateData', () => { + it('loads vintage and reroute clipboard templates and restores clipboard state', () => { + localStorage.setItem('litegrapheditor_clipboard', 'previous') + const graphMouse: [number, number] = [0, 0] + const selected_nodes = { + first: { pos: [10, 20], size: [100, 30] }, + second: { pos: [20, 80], size: [100, 40] } + } as PartialDeep> + const pasteFromClipboard = vi.fn() + singletonApp.canvas = fromPartial({ + ...mockCanvas, + graph_mouse: graphMouse, + selected_nodes, + pasteFromClipboard + }) + + app.loadTemplateData({ + templates: [{}, { data: '{"nodes":[]}' }, { data: '{"reroutes":[1]}' }] + }) + + expect(deserialiseAndCreate).toHaveBeenCalledWith( + '{"nodes":[]}', + singletonApp.canvas + ) + expect(pasteFromClipboard).toHaveBeenCalledTimes(1) + expect(graphMouse[1]).toBe(170) + expect(localStorage.getItem('litegrapheditor_clipboard')).toBe('previous') + }) + + it('ignores empty template payloads', () => { + app.loadTemplateData({}) + + expect(deserialiseAndCreate).not.toHaveBeenCalled() + }) + }) + + describe('refreshComboInNodes', () => { + it('shows success toast and removes the pending toast after node defs reload', async () => { + app.vueAppReady = true + vi.spyOn(app, 'reloadNodeDefs').mockResolvedValue() + + await app.refreshComboInNodes() + + expect(mockToastStore.add).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'info' }) + ) + expect(mockToastStore.add).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }) + ) + expect(mockToastStore.remove).toHaveBeenCalledWith( + mockToastStore.add.mock.calls[0][0] + ) + }) + + it('shows failure toast, removes the pending toast, and rethrows reload failures', async () => { + app.vueAppReady = true + const error = new Error('object_info failed') + vi.spyOn(app, 'reloadNodeDefs').mockRejectedValue(error) + + await expect(app.refreshComboInNodes()).rejects.toThrow(error) + + expect(mockToastStore.add).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }) + ) + expect(mockToastStore.remove).toHaveBeenCalledWith( + mockToastStore.add.mock.calls[0][0] + ) + }) + }) + + describe('reloadNodeDefs', () => { + it('syncs refreshed combo options into promoted combo host state', async () => { + const initialOptions = ['missing.safetensors'] + const refreshedOptions = ['missing.safetensors', 'present.safetensors'] + + const rootGraph = createTestRootGraph() + const subgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'ckpt_name', type: '*' }] + }) + + const interiorNode = new LGraphNode( + 'CheckpointLoaderSimple', + 'CheckpointLoaderSimple' + ) + const interiorInput = interiorNode.addInput('ckpt_name', '*') + interiorInput.widget = { name: 'ckpt_name' } + const interiorWidget = interiorNode.addWidget( + 'combo', + 'ckpt_name', + 'missing.safetensors', + () => {}, + { values: initialOptions } + ) + subgraph.add(interiorNode) + subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode) + + const host = createTestSubgraphNode(subgraph) + rootGraph.add(host) + + const hostWidgetId = host.inputs[0].widgetId + if (!hostWidgetId) throw new Error('Expected a promoted host widgetId') + + const widgetValueStore = useWidgetValueStore() + expect(widgetValueStore.getWidget(hostWidgetId)?.options).toEqual({ + values: initialOptions + }) + + const defs: Record = { + CheckpointLoaderSimple: { + name: 'CheckpointLoaderSimple', + display_name: 'CheckpointLoaderSimple', + category: 'loaders', + python_module: 'nodes', + description: '', + input: { + required: { + ckpt_name: [refreshedOptions, {}] + }, + optional: {} + }, + output: [], + output_name: [], + output_tooltips: [], + output_node: false, + deprecated: false, + experimental: false + } + } + Reflect.set(app, 'rootGraphInternal', rootGraph) + vi.spyOn(app, 'getNodeDefs').mockResolvedValue(defs) + vi.spyOn(app, 'registerNodeDef').mockResolvedValue(undefined) + + await app.reloadNodeDefs() + + expect(interiorWidget.options.values).toEqual(refreshedOptions) + expect(widgetValueStore.getWidget(hostWidgetId)?.options.values).toEqual( + refreshedOptions + ) + expect(mockExtensionService.invokeExtensionsAsync).toHaveBeenCalledWith( + 'refreshComboInNodes', + defs + ) + }) + + it('skips promoted host option sync when inputs do not resolve to combo state', async () => { + const rootGraph = createTestRootGraph() + rootGraph.add(new LGraphNode('PlainNode', 'PlainNode')) + + const unresolvedSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'orphan', type: '*' }] + }) + const unresolvedHost = createTestSubgraphNode(unresolvedSubgraph) + unresolvedHost.addInput('manual', '*') + rootGraph.add(unresolvedHost) + + const textSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'text', type: '*' }] + }) + const textNode = new LGraphNode('TextNode', 'TextNode') + const textInput = textNode.addInput('text', '*') + textInput.widget = { name: 'text' } + textNode.addWidget('text', 'text', 'old text', () => {}) + textSubgraph.add(textNode) + textSubgraph.inputNode.slots[0].connect(textNode.inputs[0], textNode) + const textHost = createTestSubgraphNode(textSubgraph) + rootGraph.add(textHost) + + const comboSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'mode', type: '*' }] + }) + const comboNode = new LGraphNode('ComboNode', 'ComboNode') + const comboInput = comboNode.addInput('mode', '*') + comboInput.widget = { name: 'mode' } + comboNode.addWidget('combo', 'mode', 'first', () => {}, { + values: ['first', 'second'] + }) + comboSubgraph.add(comboNode) + comboSubgraph.inputNode.slots[0].connect(comboNode.inputs[0], comboNode) + const comboHost = createTestSubgraphNode(comboSubgraph) + rootGraph.add(comboHost) + + const widgetValueStore = useWidgetValueStore() + const textWidgetId = textHost.inputs[0].widgetId + const comboWidgetId = comboHost.inputs[0].widgetId + if (!textWidgetId || !comboWidgetId) { + throw new Error('Expected promoted host widget ids') + } + widgetValueStore.deleteWidget(comboWidgetId) + + Reflect.set(app, 'rootGraphInternal', rootGraph) + vi.spyOn(app, 'getNodeDefs').mockResolvedValue({}) + vi.spyOn(app, 'registerNodeDef').mockResolvedValue(undefined) + + await app.reloadNodeDefs() + + expect(widgetValueStore.getWidget(textWidgetId)?.value).toBe('old text') + expect(widgetValueStore.getWidget(comboWidgetId)).toBeUndefined() + expect(mockExtensionService.invokeExtensionsAsync).toHaveBeenCalledWith( + 'refreshComboInNodes', + {} + ) + }) + + it('refreshes optional V2 combo specs and media node outputs', async () => { + const graph = new LGraph() + Reflect.set(app, 'rootGraphInternal', graph) + const mediaNode = new LGraphNode('MediaNode', 'MediaNode') + mediaNode.addWidget('combo', 'mode', 'old', () => {}, { + values: ['old'] + }) + mediaNode.refreshComboInNode = vi.fn() + graph.add(mediaNode) + const defs: Record = { + MediaNode: { + name: 'MediaNode', + display_name: 'MediaNode', + category: 'media', + python_module: 'nodes', + description: '', + input: { + required: {}, + optional: { + mode: ['COMBO', { options: ['new', 'newer'] }] + } + }, + output: [], + output_name: [], + output_tooltips: [], + output_node: false, + deprecated: false, + experimental: false + } + } + Reflect.set(mediaNode, 'previewMediaType', 'image') + vi.mocked(isImageNode).mockReturnValue(true) + vi.spyOn(app, 'getNodeDefs').mockResolvedValue(defs) + vi.spyOn(app, 'registerNodeDef').mockResolvedValue(undefined) + + await app.reloadNodeDefs() + + expect(mediaNode.widgets?.[0].options.values).toEqual(['new', 'newer']) + expect(mediaNode.refreshComboInNode).toHaveBeenCalledWith(defs) + expect(mockNodeOutputStore.refreshNodeOutputs).toHaveBeenCalledWith( + mediaNode + ) + }) + }) + + describe('refreshMissingModels', () => { + it('delegates to the app-independent missing model refresh pipeline', async () => { + const graph = { + nodes: [], + serialize: vi.fn(() => createWorkflowGraphData()) + } + const result = { + missingModels: [], + confirmedCandidates: [] + } + Reflect.set(app, 'rootGraphInternal', graph) + vi.spyOn(app, 'reloadNodeDefs').mockResolvedValue() + mockRefreshMissingModelPipeline.mockResolvedValue(result) + + await expect(app.refreshMissingModels({ silent: false })).resolves.toBe( + result + ) + + expect(mockRefreshMissingModelPipeline).toHaveBeenCalledWith({ + graph, + reloadNodeDefs: expect.any(Function), + missingModelStore: useMissingModelStore(), + silent: false + }) + + await mockRefreshMissingModelPipeline.mock.calls[0][0].reloadNodeDefs() + expect(app.reloadNodeDefs).toHaveBeenCalled() + }) + }) + + describe('loadGraphData', () => { + it('falls back to the default graph and runs asset scans with default options', async () => { + const rootGraph = new LGraph() + Reflect.set(app, 'rootGraphInternal', rootGraph) + const canvas = attachLoadGraphCanvas(app, { width: 0, height: 0 }) + vi.spyOn(rootGraph, 'configure').mockImplementation(() => undefined) + vi.spyOn(app, 'clean').mockImplementation(() => undefined) + const runMissingMediaPipeline = vi + .spyOn( + Object.getPrototypeOf(app) as { + runMissingMediaPipeline(silent?: boolean): Promise + }, + 'runMissingMediaPipeline' + ) + .mockResolvedValue(undefined) + const requestAnimationFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + + await app.loadGraphData() + + expect(canvas.setGraph).toHaveBeenCalledWith(rootGraph) + expect(app.clean).toHaveBeenCalled() + expect(mockRunMissingModelPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + graph: rootGraph, + silent: false + }) + ) + expect(runMissingMediaPipeline).toHaveBeenCalledWith(false) + expect(mockWorkflowService.showPendingWarnings).toHaveBeenCalledWith( + undefined, + { silent: false } + ) + expect(canvas.resize).toHaveBeenCalled() + expect(requestAnimationFrame).toHaveBeenCalled() + expect(mockSubgraphNavigationStore.updateHash).toHaveBeenCalled() + expect(mockWorkflowService.beforeLoadNewGraph).toHaveBeenCalled() + expect(mockWorkflowService.afterLoadNewGraph).toHaveBeenCalledWith( + null, + expect.any(Object), + undefined + ) + }) + + it('validates, tracks, normalizes, and defers warning-heavy workflow loads', async () => { + const rootGraph = new LGraph() + Reflect.set(app, 'rootGraphInternal', rootGraph) + attachLoadGraphCanvas(app) + const sampler = new LGraphNode('KSampler', 'KSampler') + sampler.addWidget('combo', 'sampler_name', 'sample_euler', () => {}, { + values: ['euler'] + }) + const controlAfterGenerate = sampler.addWidget( + 'combo', + 'control_after_generate', + 'fixed', + () => {}, + { + values: ['fixed', 'randomize'] + } + ) + Reflect.set(controlAfterGenerate, 'value', true) + const ckptName = sampler.addWidget( + 'combo', + 'ckpt_name', + 'model.safetensors', + () => {}, + { + values: ['model.safetensors'] + } + ) + Reflect.set(ckptName, 'value', null) + rootGraph.add(sampler) + vi.spyOn(rootGraph, 'configure').mockImplementation(() => undefined) + mockSettingStore.get.mockImplementation((key: string) => { + if (key === 'Comfy.Validation.Workflows') return true + if (key === 'Comfy.EnableWorkflowViewRestore') return true + return key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined + }) + mockWorkflowValidation.validateWorkflow.mockImplementation( + async (graphData: ComfyWorkflowJSON) => ({ graphData }) + ) + mockFindLegacyRerouteNodes.mockReturnValue([{}]) + mockNoNativeReroutes.mockReturnValue(true) + mockNodeReplacementStore.getReplacementFor.mockReturnValue({ + node_type: 'ReplacementNode' + }) + mockEnsureCorrectLayoutScale.mockReturnValue(true) + const runMissingMediaPipeline = vi + .spyOn( + Object.getPrototypeOf(app) as { + runMissingMediaPipeline(silent?: boolean): Promise + }, + 'runMissingMediaPipeline' + ) + .mockResolvedValue(undefined) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + callback(0) + return 1 + } + ) + const workflow = new ComfyWorkflow({ + path: 'workflows/shared.json', + modified: 0, + size: 0 + }) + workflow.shareId = 'workflow-share' + const graphData = fromPartial({ + ...createWorkflowGraphData(), + nodes: [ + { + id: 1, + type: 'Missing<&>', + mode: 0, + properties: { cnr_id: 'missing-pack' } + }, + { + id: 2, + type: 'MutedMissing', + mode: LGraphEventMode.NEVER + } + ], + extra: { + ds: { + offset: [25, 50], + scale: 0.5 + } + } + }) + + await app.loadGraphData(graphData, false, true, workflow, { + checkForRerouteMigration: true, + openSource: 'template', + deferWarnings: true, + silentAssetErrors: true + }) + + expect(mockWorkflowValidation.validateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ version: 0.4 }) + ) + expect(mockToastStore.add).toHaveBeenCalledWith( + expect.objectContaining({ + group: 'reroute-migration', + severity: 'warn' + }) + ) + expect(mockSubgraphService.loadSubgraphs).toHaveBeenCalledWith( + expect.objectContaining({ version: 0.4 }) + ) + expect(mockExtensionService.invokeExtensionsAsync).toHaveBeenCalledWith( + 'beforeConfigureGraph', + expect.any(Object), + expect.any(Array) + ) + expect(sampler.widgets?.map((widget) => widget.value)).toEqual([ + 'euler', + 'randomize', + 'model.safetensors' + ]) + expect(mockSyncLayoutStoreNodeBoundsFromGraph).toHaveBeenCalledWith( + rootGraph + ) + expect(mockTelemetry.trackWorkflowOpened).toHaveBeenCalledWith( + expect.objectContaining({ + missing_node_count: 1, + missing_node_types: ['Missing<&>'], + open_source: 'template', + share_id: 'workflow-share' + }) + ) + expect(mockTelemetry.trackWorkflowImported).toHaveBeenCalledWith( + expect.objectContaining({ + missing_node_count: 1, + open_source: 'template' + }) + ) + expect(mockWorkflowService.afterLoadNewGraph).toHaveBeenCalledWith( + workflow, + expect.any(Object), + 'workflow-share' + ) + expect(mockRunMissingModelPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + graph: rootGraph, + silent: true + }) + ) + expect(runMissingMediaPipeline).toHaveBeenCalledWith(true) + expect(mockWorkflowService.showPendingWarnings).not.toHaveBeenCalled() + expect(mockLitegraphService.fitView).toHaveBeenCalled() + }) + + it('skips asset scans, aborts verification, and resets missing asset candidates', async () => { + const rootGraph = new LGraph() + Reflect.set(app, 'rootGraphInternal', rootGraph) + attachLoadGraphCanvas(app) + vi.spyOn(rootGraph, 'configure').mockImplementation(() => undefined) + vi.spyOn(app, 'clean').mockImplementation(() => undefined) + const modelStore = useMissingModelStore() + const mediaStore = useMissingMediaStore() + const modelController = new AbortController() + const mediaController = new AbortController() + const abortModel = vi.spyOn(modelController, 'abort') + const abortMedia = vi.spyOn(mediaController, 'abort') + vi.spyOn(modelStore, 'createVerificationAbortController').mockReturnValue( + modelController + ) + vi.spyOn(mediaStore, 'createVerificationAbortController').mockReturnValue( + mediaController + ) + const setMissingModels = vi.spyOn(modelStore, 'setMissingModels') + const setMissingMedia = vi.spyOn(mediaStore, 'setMissingMedia') + vi.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + callback(0) + return 1 + } + ) + + await app.loadGraphData(createWorkflowGraphData(), true, false, null, { + skipAssetScans: true + }) + + expect(abortModel).toHaveBeenCalled() + expect(abortMedia).toHaveBeenCalled() + expect(setMissingModels).toHaveBeenCalledWith([]) + expect(setMissingMedia).toHaveBeenCalledWith([]) + expect(mockRunMissingModelPipeline).not.toHaveBeenCalled() + expect(mockWorkflowService.showPendingWarnings).toHaveBeenCalled() + }) + + it('shows a load error and clears loading state when graph configuration fails', async () => { + const rootGraph = new LGraph() + Reflect.set(app, 'rootGraphInternal', rootGraph) + attachLoadGraphCanvas(app) + const error = new Error('bad graph') + vi.spyOn(rootGraph, 'configure').mockImplementation(() => { + throw error + }) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await app.loadGraphData(createWorkflowGraphData()) + + expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith( + error, + expect.objectContaining({ + reportType: 'loadWorkflowError' + }) + ) + expect(mockRunMissingModelPipeline).not.toHaveBeenCalled() + }) + + it('resets invalid combo values and restores saved canvas view when loading malformed graph input', async () => { + const rootGraph = new LGraph() + Reflect.set(app, 'rootGraphInternal', rootGraph) + attachLoadGraphCanvas(app) + const node = new LGraphNode('PrimitiveNode', 'PrimitiveNode') + node.addWidget('combo', 'control_after_generate', 'invalid', () => {}, { + values: ['fixed', 'randomize'] + }) + rootGraph.add(node) + vi.spyOn(rootGraph, 'configure').mockImplementation(() => undefined) + vi.spyOn(app, 'clean').mockImplementation(() => undefined) + mockSettingStore.get.mockImplementation((key: string) => { + if (key === 'Comfy.EnableWorkflowViewRestore') return true + return key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined + }) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + callback(0) + return 1 + } + ) + + await app.loadGraphData([], true, true) + + expect(node.widgets?.[0].value).toBe('fixed') + expect(mockLitegraphService.fitView).toHaveBeenCalled() + }) + }) + + describe('handleFileList', () => { + it('should create image nodes for each file in the list', async () => { + const mockNode1 = createMockNode({ id: 1 }) + const mockNode2 = createMockNode({ id: 2 }) + const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' }) + + vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2]) + vi.mocked(createNode).mockResolvedValue(mockBatchNode) + + const file1 = createTestFile('test1.png', 'image/png') + const file2 = createTestFile('test2.jpg', 'image/jpeg') + const files = [file1, file2] + + await app.handleFileList(files) + + expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files) + expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode') + expect(mockCanvas.selectItems).toHaveBeenCalledWith([ + mockNode1, + mockNode2, + mockBatchNode + ]) + expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0) + expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1) + }) + + it('should select single image node without batch node', async () => { + const mockNode1 = createMockNode({ id: 1 }) + vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1]) + + const file = createTestFile('test.png', 'image/png') + + await app.handleFileList([file]) + + expect(createNode).not.toHaveBeenCalled() + expect(mockCanvas.selectItems).toHaveBeenCalledWith([mockNode1]) + expect(mockNode1.connect).not.toHaveBeenCalled() + }) + + it('should handle empty file list', async () => { + await app.handleFileList([]) + + expect(pasteImageNodes).not.toHaveBeenCalled() + expect(createNode).not.toHaveBeenCalled() + }) + + it('should not process unsupported file types', async () => { + const invalidFile = createTestFile('test.pdf', 'application/pdf') + + await app.handleFileList([invalidFile]) + + expect(pasteImageNodes).not.toHaveBeenCalled() + expect(createNode).not.toHaveBeenCalled() + }) + + it('should return when image paste creates no nodes', async () => { + vi.mocked(pasteImageNodes).mockResolvedValue([]) + + await app.handleFileList([createTestFile('empty.png', 'image/png')]) + + expect(createNode).not.toHaveBeenCalled() + expect(mockCanvas.selectItems).not.toHaveBeenCalled() + }) + + it('should return when batch node creation fails', async () => { + const first = createMockNode({ id: 1 }) + const second = createMockNode({ id: 2 }) + vi.mocked(pasteImageNodes).mockResolvedValue([first, second]) + vi.mocked(createNode).mockResolvedValue(null) + + await app.handleFileList([ + createTestFile('first.png', 'image/png'), + createTestFile('second.png', 'image/png') + ]) + + expect(mockCanvas.selectItems).not.toHaveBeenCalled() + expect(first.connect).not.toHaveBeenCalled() + expect(second.connect).not.toHaveBeenCalled() + }) + }) + + describe('handleAudioFileList', () => { + it('should create audio nodes and select them', async () => { + const mockNode1 = createMockNode({ id: 1, type: 'LoadAudio' }) + const mockNode2 = createMockNode({ id: 2, type: 'LoadAudio' }) + vi.mocked(pasteAudioNodes).mockResolvedValue([mockNode1, mockNode2]) + + const file1 = createTestFile('test1.mp3', 'audio/mpeg') + const file2 = createTestFile('test2.wav', 'audio/wav') + + await app.handleAudioFileList([file1, file2]) + + expect(pasteAudioNodes).toHaveBeenCalledWith(mockCanvas, [file1, file2]) + expect(mockCanvas.selectItems).toHaveBeenCalledWith([ + mockNode1, + mockNode2 + ]) + }) + + it('should not select when no nodes created', async () => { + vi.mocked(pasteAudioNodes).mockResolvedValue([]) + + await app.handleAudioFileList([createTestFile('test.mp3', 'audio/mpeg')]) + + expect(mockCanvas.selectItems).not.toHaveBeenCalled() + }) + }) + + describe('handleVideoFileList', () => { + it('should create video nodes and select them', async () => { + const mockNode1 = createMockNode({ id: 1, type: 'LoadVideo' }) + const mockNode2 = createMockNode({ id: 2, type: 'LoadVideo' }) + vi.mocked(pasteVideoNodes).mockResolvedValue([mockNode1, mockNode2]) + + const file1 = createTestFile('test1.mp4', 'video/mp4') + const file2 = createTestFile('test2.webm', 'video/webm') + + await app.handleVideoFileList([file1, file2]) + + expect(pasteVideoNodes).toHaveBeenCalledWith(mockCanvas, [file1, file2]) + expect(mockCanvas.selectItems).toHaveBeenCalledWith([ + mockNode1, + mockNode2 + ]) + }) + + it('should not select when no nodes created', async () => { + vi.mocked(pasteVideoNodes).mockResolvedValue([]) + + await app.handleVideoFileList([createTestFile('test.mp4', 'video/mp4')]) + + expect(mockCanvas.selectItems).not.toHaveBeenCalled() + }) + }) + + describe('positionBatchNodes', () => { + it('should position batch node to the right of first node', () => { + const mockNode1 = createMockNode({ + pos: [100, 200], + getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400])) + }) + const mockBatchNode = createMockNode({ pos: [0, 0] }) + + app.positionBatchNodes([mockNode1], mockBatchNode) + + expect(mockBatchNode.pos).toEqual([500, 230]) + }) + + it('should stack multiple image nodes vertically', () => { + const mockNode1 = createMockNode({ + pos: [100, 200], + type: 'LoadImage', + getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400])) + }) + const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' }) + const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' }) + const mockBatchNode = createMockNode({ pos: [0, 0] }) + + app.positionBatchNodes([mockNode1, mockNode2, mockNode3], mockBatchNode) + + expect(mockNode1.pos).toEqual([100, 200]) + expect(mockNode2.pos).toEqual([100, 594]) + expect(mockNode3.pos).toEqual([100, 963]) + }) + + it('should call graph change once for all nodes', () => { + const mockNode1 = createMockNode({ + getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400])) + }) + const mockBatchNode = createMockNode() + + app.positionBatchNodes([mockNode1], mockBatchNode) + + expect(mockCanvas.graph?.change).toHaveBeenCalledTimes(1) + }) + + it('should not add image spacing for non-image nodes', () => { + const first = createMockNode({ + type: 'LoadAudio', + getBounding: vi.fn(() => new Float64Array([10, 20, 30, 40])) + }) + const second = createMockNode({ type: 'LoadAudio', pos: [0, 0] }) + const batchNode = createMockNode({ pos: [0, 0] }) + + app.positionBatchNodes([first, second], batchNode) + + expect(batchNode.pos).toEqual([140, 50]) + expect(second.pos).toEqual([10, 70]) + }) + }) + + describe('positionNodes', () => { + it('should leave single nodes in place', () => { + const node = createMockNode({ pos: [5, 10] }) + + app.positionNodes([node]) + + expect(node.pos).toEqual([5, 10]) + expect(mockCanvas.graph?.change).not.toHaveBeenCalled() + }) + + it('should stack later nodes below the first node', () => { + const first = createMockNode({ + getBounding: vi.fn(() => new Float64Array([10, 20, 30, 40])) + }) + const second = createMockNode({ pos: [0, 0] }) + const third = createMockNode({ pos: [0, 0] }) + + app.positionNodes([first, second, third]) + + expect(second.pos).toEqual([10, 220]) + expect(third.pos).toEqual([10, 395]) + expect(mockCanvas.graph?.change).toHaveBeenCalledTimes(1) + }) + }) + + describe('isApiJson', () => { + it('accepts only non-empty API prompt records', () => { + expect(app.isApiJson(null)).toBe(false) + expect(app.isApiJson([])).toBe(false) + expect(app.isApiJson({})).toBe(false) + expect(app.isApiJson({ 1: null })).toBe(false) + expect(app.isApiJson({ 1: { class_type: 123, inputs: {} } })).toBe(false) + expect( + app.isApiJson({ 1: { class_type: 'PreviewAny', inputs: [] } }) + ).toBe(false) + expect( + app.isApiJson({ 1: { class_type: 'PreviewAny', inputs: {} } }) + ).toBe(true) + }) + }) + + describe('loadApiJson', () => { + it('builds graph nodes, assigns metadata titles, and wires inputs', () => { + const widgetCallback = vi.fn() + class ApiSourceNode extends LGraphNode { + constructor(title?: string) { + super('ApiSource', title) + this.addOutput('out', 'number') + } + } + class ApiTargetNode extends LGraphNode { + constructor(title?: string) { + super('ApiTarget', title) + this.addInput('incoming', 'number') + this.addWidget('number', 'strength', 0, widgetCallback) + } + } + LiteGraph.registerNodeType('ApiSource', ApiSourceNode) + LiteGraph.registerNodeType('ApiTarget', ApiTargetNode) + const graph = new LGraph() + Reflect.set(singletonApp, 'rootGraphInternal', graph) + + try { + singletonApp.loadApiJson( + { + source: { + class_type: 'ApiSource', + inputs: {}, + _meta: { title: 'Source title' } + }, + 2: { + class_type: 'ApiTarget', + inputs: { + incoming: ['source', 0], + strength: 0.75 + }, + _meta: { title: 'Target title' } + } + }, + 'api-prompt' + ) + + const source = graph.nodes.find((node) => node.type === 'ApiSource') + const target = graph.nodes.find((node) => node.type === 'ApiTarget') + expect(source?.title).toBe('Source title') + expect(target?.title).toBe('Target title') + expect(target?.widgets?.[0].value).toBe(0.75) + expect(widgetCallback).toHaveBeenCalledWith(0.75) + expect(target?.inputs?.[0].link).not.toBeNull() + expect(mockWorkflowService.afterLoadNewGraph).toHaveBeenCalledWith( + 'api-prompt', + expect.any(Object) + ) + } finally { + LiteGraph.unregisterNodeType('ApiSource') + LiteGraph.unregisterNodeType('ApiTarget') + } + }) + + it('surfaces missing node types and skips nodes that cannot be created', () => { + const graph = new LGraph() + Reflect.set(singletonApp, 'rootGraphInternal', graph) + const showMissingNodesError = vi + .spyOn( + Object.getPrototypeOf(singletonApp) as { + showMissingNodesError(types: string[]): void + }, + 'showMissingNodesError' + ) + .mockImplementation(() => undefined) + + singletonApp.loadApiJson( + { + 1: { + class_type: 'MissingApiNode', + inputs: {}, + _meta: { title: 'MissingApiNode' } + } + }, + 'missing' + ) + + expect(showMissingNodesError).toHaveBeenCalledWith(['MissingApiNode']) + expect(graph.nodes).toHaveLength(0) + }) + + it('skips missing link sources and converts widgets into inputs when needed', () => { + const widgetCallback = vi.fn() + class WidgetInputNode extends LGraphNode { + constructor(title?: string) { + super('WidgetInputNode', title) + this.addWidget('number', 'strength', 0, widgetCallback) + } + + convertWidgetToInput = () => { + this.addInput('strength', 'number') + return true + } + } + class SourceNode extends LGraphNode { + constructor(title?: string) { + super('SourceNode', title) + this.addOutput('value', 'number') + } + } + class NoInputNode extends LGraphNode { + constructor(title?: string) { + super('NoInputNode', title) + } + } + LiteGraph.registerNodeType('WidgetInputNode', WidgetInputNode) + LiteGraph.registerNodeType('SourceNode', SourceNode) + LiteGraph.registerNodeType('NoInputNode', NoInputNode) + const graph = new LGraph() + Reflect.set(singletonApp, 'rootGraphInternal', graph) + + try { + singletonApp.loadApiJson( + { + 1: { + class_type: 'WidgetInputNode', + inputs: { + strength: [3, 0], + missing: [404, 0] + }, + _meta: { title: 'WidgetInputNode' } + }, + 2: { + class_type: 'NoInputNode', + inputs: {}, + _meta: { title: 'NoInputNode' } + }, + 3: { + class_type: 'SourceNode', + inputs: {}, + _meta: { title: 'SourceNode' } + } + }, + 'converted' + ) + + const converted = graph.nodes.find( + (node) => node.type === 'WidgetInputNode' + ) + expect(converted?.inputs?.[0].name).toBe('strength') + expect(converted?.inputs?.[0].link).not.toBeNull() + expect(widgetCallback).not.toHaveBeenCalled() + } finally { + LiteGraph.unregisterNodeType('WidgetInputNode') + LiteGraph.unregisterNodeType('SourceNode') + LiteGraph.unregisterNodeType('NoInputNode') + } + }) + + it('continues when widget-to-input conversion throws', () => { + class ThrowingWidgetNode extends LGraphNode { + constructor(title?: string) { + super('ThrowingWidgetNode', title) + this.addWidget('number', 'strength', 0, () => {}) + } + + convertWidgetToInput = () => { + throw new Error('cannot convert') + } + } + class SourceNode extends LGraphNode { + constructor(title?: string) { + super('ThrowingSourceNode', title) + this.addOutput('value', 'number') + } + } + LiteGraph.registerNodeType('ThrowingWidgetNode', ThrowingWidgetNode) + LiteGraph.registerNodeType('ThrowingSourceNode', SourceNode) + const graph = new LGraph() + Reflect.set(singletonApp, 'rootGraphInternal', graph) + + try { + singletonApp.loadApiJson( + { + 1: { + class_type: 'ThrowingSourceNode', + inputs: {}, + _meta: { title: 'ThrowingSourceNode' } + }, + 2: { + class_type: 'ThrowingWidgetNode', + inputs: { + strength: [1, 0] + }, + _meta: { title: 'ThrowingWidgetNode' } + } + }, + 'conversion-failed' + ) + + const target = graph.nodes.find( + (node) => node.type === 'ThrowingWidgetNode' + ) + expect(target?.inputs).toHaveLength(0) + } finally { + LiteGraph.unregisterNodeType('ThrowingWidgetNode') + LiteGraph.unregisterNodeType('ThrowingSourceNode') + } + }) + }) + + describe('handleFile', () => { + it('should handle image files by creating LoadImage node', async () => { + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({}) + + const mockNode = createMockNode() + vi.mocked(createNode).mockResolvedValue(mockNode) + + const imageFile = createTestFile('test.png', 'image/png') + + await app.handleFile(imageFile) + + expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage') + expect(pasteImageNode).toHaveBeenCalledWith( + mockCanvas, + expect.any(DataTransferItemList), + mockNode + ) + }) + + it('should handle audio files by creating LoadAudio node', async () => { + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({}) + + const mockNode = createMockNode({ type: 'LoadAudio' }) + vi.mocked(createNode).mockResolvedValue(mockNode) + + const audioFile = createTestFile('test.mp3', 'audio/mpeg') + + await app.handleFile(audioFile) + + expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadAudio') + expect(pasteAudioNode).toHaveBeenCalledWith( + mockCanvas, + expect.any(DataTransferItemList), + mockNode + ) + }) + + it('should handle video files by creating LoadVideo node', async () => { + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({}) + + const mockNode = createMockNode({ type: 'LoadVideo' }) + vi.mocked(createNode).mockResolvedValue(mockNode) + + const videoFile = createTestFile('test.mp4', 'video/mp4') + + await app.handleFile(videoFile) + + expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadVideo') + expect(pasteVideoNode).toHaveBeenCalledWith( + mockCanvas, + expect.any(DataTransferItemList), + mockNode + ) + }) + + it('should handle image files with non-workflow metadata by creating LoadImage node', async () => { + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ + Software: 'gnome-screenshot' + }) + + const mockNode = createMockNode() + vi.mocked(createNode).mockResolvedValue(mockNode) + + const imageFile = createTestFile('screenshot.png', 'image/png') + + await app.handleFile(imageFile) + + expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage') + expect(pasteImageNode).toHaveBeenCalledWith( + mockCanvas, + expect.any(DataTransferItemList), + mockNode + ) + }) + + it('loads workflow metadata and preserves the open source options', async () => { + const workflow = createWorkflowGraphData() + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ + workflow: JSON.stringify(workflow) + }) + const loadGraphData = vi + .spyOn(app, 'loadGraphData') + .mockResolvedValue(undefined) + + await app.handleFile( + createTestFile('review.workflow.png', 'image/png'), + 'file_drop', + { + deferWarnings: true + } + ) + + expect(loadGraphData).toHaveBeenCalledWith( + workflow, + true, + true, + 'review.workflow', + { openSource: 'file_drop', deferWarnings: true } + ) + }) + + it('falls back to parameters when workflow metadata is invalid', async () => { + const graph = new LGraph() + Reflect.set(app, 'rootGraphInternal', graph) + vi.spyOn(graph, 'serialize').mockReturnValue(createSerialisedGraph()) + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ + workflow: '[]', + parameters: 'Steps: 12' + }) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await app.handleFile(createTestFile('invalid-workflow.png', 'image/png')) + + expect(mockToastStore.addAlert).toHaveBeenCalled() + expect(importA1111).toHaveBeenCalledWith(graph, 'Steps: 12') + expect(mockWorkflowService.afterLoadNewGraph).toHaveBeenCalledWith( + 'invalid-workflow', + expect.any(Object) + ) + }) + + it('loads template metadata and object workflow payloads', async () => { + const workflow = createWorkflowGraphData() + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ + templates: [{ name: 'Starter', data: '{"nodes":[]}' }], + workflow + }) + const loadTemplateData = vi.spyOn(app, 'loadTemplateData') + const loadGraphData = vi + .spyOn(app, 'loadGraphData') + .mockResolvedValue(undefined) + + await app.handleFile(createTestFile('template.png', 'image/png')) + + expect(loadTemplateData).toHaveBeenCalledWith({ + templates: [{ name: 'Starter', data: '{"nodes":[]}' }] + }) + expect(loadGraphData).toHaveBeenCalledWith( + workflow, + true, + true, + 'template', + { openSource: undefined, deferWarnings: undefined } + ) + }) + + it('loads API prompt metadata before falling back to parameters', async () => { + const prompt: ComfyApiWorkflow = { + '1': { + class_type: 'PreviewAny', + inputs: {}, + _meta: { title: 'Preview' } + } + } + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ + prompt: JSON.stringify(prompt), + parameters: 'ignored fallback' + }) + const loadApiJson = vi + .spyOn(app, 'loadApiJson') + .mockImplementation(() => undefined) + + await app.handleFile(createTestFile('prompt.png', 'image/png')) + + expect(loadApiJson).toHaveBeenCalledWith(prompt, 'prompt') + expect(importA1111).not.toHaveBeenCalled() + }) + + it('loads object API prompt metadata', async () => { + const prompt: ComfyApiWorkflow = { + '1': { + class_type: 'PreviewAny', + inputs: {}, + _meta: { title: 'PreviewAny' } + } + } + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ prompt }) + const loadApiJson = vi + .spyOn(app, 'loadApiJson') + .mockImplementation(() => undefined) + + await app.handleFile(createTestFile('prompt-object.png', 'image/png')) + + expect(loadApiJson).toHaveBeenCalledWith(prompt, 'prompt-object') + }) + + it('falls back to parameters when prompt metadata cannot be parsed', async () => { + const graph = new LGraph() + Reflect.set(app, 'rootGraphInternal', graph) + vi.spyOn(graph, 'serialize').mockReturnValue(createSerialisedGraph()) + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ + prompt: '{', + parameters: 'Steps: 6' + }) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await app.handleFile(createTestFile('bad-prompt.png', 'image/png')) + + expect(importA1111).toHaveBeenCalledWith(graph, 'Steps: 6') + expect(mockWorkflowService.afterLoadNewGraph).toHaveBeenCalledWith( + 'bad-prompt', + expect.any(Object) + ) + }) + + it('imports A1111 parameters as the final metadata fallback', async () => { + const graph = new LGraph() + Reflect.set(app, 'rootGraphInternal', graph) + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({ + parameters: 'Steps: 20' + }) + + await app.handleFile(createTestFile('parameters.png', 'image/png')) + + expect(importA1111).toHaveBeenCalledWith(graph, 'Steps: 20') + expect(mockWorkflowService.beforeLoadNewGraph).toHaveBeenCalled() + expect(mockWorkflowService.afterLoadNewGraph).toHaveBeenCalledWith( + 'parameters', + expect.any(Object) + ) + }) + + it('shows a load error for unsupported files without metadata', async () => { + vi.mocked(getWorkflowDataFromFile).mockResolvedValue({}) + + await app.handleFile(createTestFile('notes.pdf', 'application/pdf')) + + expect(mockToastStore.addAlert).toHaveBeenCalled() + expect(createNode).not.toHaveBeenCalled() + }) + }) + + describe('drop handler', () => { + it('syncs graph_mouse and routes mixed file drops by media type', async () => { + const graphMouse: [number, number] = [-999, -999] + const adjustMouseEvent = vi.fn((e: DragEvent) => { + ;(e as DragEvent & { canvasX: number; canvasY: number }).canvasX = 123 + ;(e as DragEvent & { canvasX: number; canvasY: number }).canvasY = 456 + }) + app.canvas = fromPartial({ + ...mockCanvas, + graph_mouse: graphMouse, + adjustMouseEvent + }) + const image = createTestFile('image.png', 'image/png') + const audio = createTestFile('audio.wav', 'audio/wav') + const video = createTestFile('video.mp4', 'video/mp4') + const workflow = createTestFile('workflow.json', 'application/json') + mockExtractFilesFromDragEvent.mockResolvedValue([ + image, + audio, + video, + workflow + ]) + vi.spyOn(app, 'handleFileList').mockResolvedValue(undefined) + vi.spyOn(app, 'handleAudioFileList').mockResolvedValue(undefined) + vi.spyOn(app, 'handleVideoFileList').mockResolvedValue(undefined) + vi.spyOn(app, 'handleFile').mockResolvedValue(undefined) + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + + document.dispatchEvent(new DragEvent('drop')) + await flushDropHandler() + + expect(adjustMouseEvent).toHaveBeenCalledTimes(1) + expect(graphMouse).toEqual([123, 456]) + expect(app.handleFileList).toHaveBeenCalledWith([image]) + expect(app.handleAudioFileList).toHaveBeenCalledWith([audio]) + expect(app.handleVideoFileList).toHaveBeenCalledWith([video]) + expect(app.handleFile).toHaveBeenCalledWith(workflow, 'file_drop', { + deferWarnings: true + }) + expect(mockWorkspaceStore.spinner).toBe(false) + expect(mockWorkflowService.showPendingWarnings).toHaveBeenCalled() + }) + + it('routes multi-image drops without requiring every media type', async () => { + app.canvas = fromPartial({ + ...mockCanvas, + graph_mouse: [0, 0], + adjustMouseEvent: vi.fn((event: DragEvent) => { + ;(event as DragEvent & { canvasX: number; canvasY: number }).canvasX = + 3 + ;(event as DragEvent & { canvasY: number }).canvasY = 4 + }) + }) + const first = createTestFile('first.png', 'image/png') + const second = createTestFile('second.png', 'image/png') + const workflow = createTestFile('workflow.json', 'application/json') + mockExtractFilesFromDragEvent.mockResolvedValue([first, second, workflow]) + vi.spyOn(app, 'handleFileList').mockResolvedValue(undefined) + vi.spyOn(app, 'handleAudioFileList').mockResolvedValue(undefined) + vi.spyOn(app, 'handleVideoFileList').mockResolvedValue(undefined) + vi.spyOn(app, 'handleFile').mockResolvedValue(undefined) + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + document.dispatchEvent(new DragEvent('drop')) + await flushDropHandler() + + expect(app.handleFileList).toHaveBeenCalledWith([first, second]) + expect(app.handleAudioFileList).not.toHaveBeenCalled() + expect(app.handleVideoFileList).not.toHaveBeenCalled() + expect(app.handleFile).toHaveBeenCalledWith(workflow, 'file_drop', { + deferWarnings: true + }) + }) + + it('routes a single dropped file through handleFile', async () => { + const workflow = createTestFile('workflow.json', 'application/json') + app.canvas = fromPartial({ + ...mockCanvas, + graph_mouse: [0, 0], + adjustMouseEvent: vi.fn((event: DragEvent) => { + ;(event as DragEvent & { canvasX: number; canvasY: number }).canvasX = + 1 + ;(event as DragEvent & { canvasY: number }).canvasY = 2 + }) + }) + mockExtractFilesFromDragEvent.mockResolvedValue([workflow]) + const handleFile = vi.spyOn(app, 'handleFile').mockResolvedValue() + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + document.dispatchEvent(new DragEvent('drop')) + await flushDropHandler() + + expect(handleFile).toHaveBeenCalledWith(workflow, 'file_drop', { + deferWarnings: true + }) + expect(mockWorkspaceStore.spinner).toBe(false) + }) + + it('does not start loading when the drop contains no files', async () => { + app.canvas = fromPartial({ + ...mockCanvas, + graph_mouse: [0, 0], + adjustMouseEvent: vi.fn() + }) + mockExtractFilesFromDragEvent.mockResolvedValue([]) + const handleFile = vi.spyOn(app, 'handleFile').mockResolvedValue() + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + document.dispatchEvent(new DragEvent('drop')) + await flushDropHandler() + + expect(handleFile).not.toHaveBeenCalled() + expect(mockWorkspaceStore.spinner).toBe(false) + }) + + it('surfaces drop routing failures', async () => { + app.canvas = fromPartial({ + ...mockCanvas, + graph_mouse: [0, 0], + adjustMouseEvent: vi.fn() + }) + mockExtractFilesFromDragEvent.mockRejectedValue(new Error('drop failed')) + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + document.dispatchEvent(new DragEvent('drop')) + await flushDropHandler() + + expect(mockToastStore.addAlert).toHaveBeenLastCalledWith( + expect.stringContaining('drop failed') + ) + }) + + it('ignores drops that were already handled by nested targets', async () => { + const handleFile = vi.spyOn(app, 'handleFile').mockResolvedValue() + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + + const event = new DragEvent('drop', { cancelable: true }) + event.preventDefault() + document.dispatchEvent(event) + await flushDropHandler() + + expect(handleFile).not.toHaveBeenCalled() + }) + + it('lets the hovered node consume a file drop before app routing', async () => { + const dragOverNode = { + id: toNodeId(1), + onDragDrop: vi.fn().mockResolvedValue(true) + } + app.dragOverNode = dragOverNode + app.canvas = fromPartial({ + ...mockCanvas, + graph_mouse: [0, 0], + adjustMouseEvent: vi.fn((event: DragEvent) => { + ;(event as DragEvent & { canvasX: number; canvasY: number }).canvasX = + 12 + ;(event as DragEvent & { canvasY: number }).canvasY = 34 + }) + }) + const handleFile = vi.spyOn(app, 'handleFile').mockResolvedValue() + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + document.dispatchEvent(new DragEvent('drop')) + await flushDropHandler() + + expect(dragOverNode.onDragDrop).toHaveBeenCalled() + expect(app.dragOverNode).toBeNull() + expect(handleFile).not.toHaveBeenCalled() + }) + + it('clears hover state on drag leave and updates it on drag over', async () => { + const canvasEl = document.createElement('canvas') + app.canvasElRef.value = canvasEl + const hoveredNode = { + id: 3, + onDragOver: vi.fn(() => true) + } + const graph = { + getNodeOnPos: vi.fn(() => hoveredNode) + } + const setDirty = vi.fn() + app.canvas = fromPartial({ + ...mockCanvas, + graph, + setDirty, + adjustMouseEvent: vi.fn((event: DragEvent) => { + ;(event as DragEvent & { canvasX: number; canvasY: number }).canvasX = + 7 + ;(event as DragEvent & { canvasY: number }).canvasY = 8 + }) + }) + const requestAnimationFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + canvasEl.dispatchEvent(new DragEvent('dragover')) + canvasEl.dispatchEvent(new DragEvent('dragleave')) + + expect(graph.getNodeOnPos).toHaveBeenCalledWith(7, 8) + expect(app.dragOverNode).toBeNull() + expect(setDirty).toHaveBeenCalledWith(false, true) + expect(requestAnimationFrame).toHaveBeenCalled() + }) + + it('clears hover state when the node rejects drag over', () => { + const canvasEl = document.createElement('canvas') + app.canvasElRef.value = canvasEl + app.dragOverNode = { + id: toNodeId(4) + } + app.canvas = fromPartial({ + ...mockCanvas, + graph: { + getNodeOnPos: vi.fn(() => ({ + id: 5, + onDragOver: vi.fn(() => false) + })) + }, + adjustMouseEvent: vi.fn((event: DragEvent) => { + ;(event as DragEvent & { canvasX: number; canvasY: number }).canvasX = + 1 + ;(event as DragEvent & { canvasY: number }).canvasY = 2 + }) + }) + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + canvasEl.dispatchEvent(new DragEvent('dragover')) + + expect(app.dragOverNode).toBeNull() + }) + + it('ignores drag leave when no node is hovered', () => { + const canvasEl = document.createElement('canvas') + app.canvasElRef.value = canvasEl + app.dragOverNode = null + + ;( + Object.getPrototypeOf(app) as { addDropHandler(): void } + ).addDropHandler.call(app) + canvasEl.dispatchEvent(new DragEvent('dragleave')) + + expect(mockCanvas.setDirty).not.toHaveBeenCalled() + }) + }) + + describe('process key handler', () => { + it('ignores events without a graph or from inputs and falls back for unbound keys', () => { + const originalProcessKey = LGraphCanvas.prototype.processKey + const fallback = vi.fn() + LGraphCanvas.prototype.processKey = fallback + ;( + Object.getPrototypeOf(app) as { addProcessKeyHandler(): void } + ).addProcessKeyHandler.call(app) + const processKey = LGraphCanvas.prototype.processKey + const graph = { change: vi.fn() } + const canvas = fromPartial({ graph }) + const inputEvent = new KeyboardEvent('keydown', { key: 'a' }) + Object.defineProperty(inputEvent, 'target', { + configurable: true, + value: document.createElement('input') + }) + const unboundEvent = new KeyboardEvent('keyup', { key: 'a' }) + + try { + processKey.call( + fromPartial({ graph: null }), + unboundEvent + ) + processKey.call(canvas, inputEvent) + processKey.call(canvas, unboundEvent) + + expect(fallback).toHaveBeenCalledTimes(1) + expect(graph.change).not.toHaveBeenCalled() + } finally { + LGraphCanvas.prototype.processKey = originalProcessKey + } + }) + + it('executes graph-canvas keybindings and suppresses litegraph fallback', () => { + const originalProcessKey = LGraphCanvas.prototype.processKey + const fallback = vi.fn() + LGraphCanvas.prototype.processKey = fallback + const execute = vi + .spyOn(useCommandStore(), 'execute') + .mockResolvedValue(undefined) + vi.spyOn(useKeybindingStore(), 'getKeybinding').mockReturnValue({ + commandId: 'test.command', + targetElementId: 'graph-canvas-container' + } as ReturnType['getKeybinding']>) + ;( + Object.getPrototypeOf(app) as { addProcessKeyHandler(): void } + ).addProcessKeyHandler.call(app) + const event = new KeyboardEvent('keydown', { key: 'a' }) + const preventDefault = vi.spyOn(event, 'preventDefault') + const stopImmediatePropagation = vi.spyOn( + event, + 'stopImmediatePropagation' + ) + const graph = { change: vi.fn() } + + try { + LGraphCanvas.prototype.processKey.call( + fromPartial({ graph }), + event + ) + + expect(execute).toHaveBeenCalledWith('test.command') + expect(graph.change).toHaveBeenCalled() + expect(preventDefault).toHaveBeenCalled() + expect(stopImmediatePropagation).toHaveBeenCalled() + expect(fallback).not.toHaveBeenCalled() + } finally { + LGraphCanvas.prototype.processKey = originalProcessKey + } + }) + }) + + describe('clean and coordinate conversion', () => { + it('clears the root graph when the canvas is not inside a subgraph', () => { + const rootGraph = new LGraph() + const clear = vi.spyOn(rootGraph, 'clear') + Reflect.set(app, 'rootGraphInternal', rootGraph) + app.canvas = fromPartial({ + ...mockCanvas, + subgraph: undefined + }) + + app.clean() + + expect(mockNodeOutputStore.resetAllOutputsAndPreviews).toHaveBeenCalled() + expect(useExecutionErrorStore().lastNodeErrors).toBeNull() + expect(clear).toHaveBeenCalled() + }) + + it('preserves the root graph when the canvas is inside a subgraph', () => { + const rootGraph = new LGraph() + const clear = vi.spyOn(rootGraph, 'clear') + Reflect.set(app, 'rootGraphInternal', rootGraph) + app.canvas = fromPartial({ + ...mockCanvas, + subgraph: new LGraph() + }) + + app.clean() + + expect(clear).not.toHaveBeenCalled() + }) + + it('throws before coordinate conversion is initialized', () => { + expect(() => app.clientPosToCanvasPos([1, 2])).toThrow( + 'clientPosToCanvasPos called before setup' + ) + expect(() => app.canvasPosToClientPos([1, 2])).toThrow( + 'canvasPosToClientPos called before setup' + ) + }) + + it('delegates coordinate conversion after setup initializes it', () => { + Reflect.set(app, 'positionConversion', { + clientPosToCanvasPos: vi.fn(() => [3, 4]), + canvasPosToClientPos: vi.fn(() => [5, 6]) + }) + + expect(app.clientPosToCanvasPos([1, 2])).toEqual([3, 4]) + expect(app.canvasPosToClientPos([1, 2])).toEqual([5, 6]) + }) + }) + + describe('API update handlers', () => { + it('routes socket events into app stores, dialogs, and canvas refreshes', () => { + vi.restoreAllMocks() + vi.spyOn(api, 'init').mockImplementation(() => undefined) + const graph = new LGraph() + const node = new LGraphNode('PreviewAny', 'PreviewAny') + node.id = toNodeId(7) + node.onExecuted = vi.fn() + graph.add(node) + Reflect.set(app, 'rootGraphInternal', graph) + const setStatus = vi.spyOn(app.ui, 'setStatus') + + ;( + Object.getPrototypeOf(app) as { addApiUpdateHandlers(): void } + ).addApiUpdateHandlers.call(app) + + ;(api as EventTarget).dispatchEvent( + new CustomEvent('status', { + detail: { exec_info: { queue_remaining: 1 } } + }) + ) + ;(api as EventTarget).dispatchEvent(new CustomEvent('progress')) + ;(api as EventTarget).dispatchEvent(new CustomEvent('executing')) + ;(api as EventTarget).dispatchEvent( + new CustomEvent('executed', { + detail: { + display_node: 7, + output: { images: [{ filename: 'preview.png' }] }, + merge: true + } + }) + ) + + expect(setStatus).toHaveBeenCalledWith({ + exec_info: { queue_remaining: 1 } + }) + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, false) + expect(mockNodeOutputStore.setNodeOutputsByExecutionId).toHaveBeenCalled() + expect(node.onExecuted).toHaveBeenCalledWith({ + images: [{ filename: 'preview.png' }] + }) + + ;(api as EventTarget).dispatchEvent( + new CustomEvent('executed', { + detail: { + node: 7, + output: { text: ['fallback'] } + } + }) + ) + expect( + mockNodeOutputStore.setNodeOutputsByExecutionId + ).toHaveBeenCalledWith('7', { text: ['fallback'] }, { merge: undefined }) + + ;(api as EventTarget).dispatchEvent( + new CustomEvent('executed', { + detail: { + display_node: '', + node: '', + output: { text: ['ignored'] } + } + }) + ) + expect( + mockNodeOutputStore.setNodeOutputsByExecutionId + ).not.toHaveBeenCalledWith( + expect.anything(), + { text: ['ignored'] }, + expect.anything() + ) + + const precondition = { code: 'credits_required' } + mockResolveAccountPrecondition.mockReturnValueOnce(precondition) + ;(api as EventTarget).dispatchEvent( + new CustomEvent('execution_error', { + detail: { + exception_type: 'credits_required', + exception_message: 'Credits required', + node_type: 'CreditNode' + } + }) + ) + expect(mockAccountPreconditionDialog.open).toHaveBeenCalledWith( + precondition, + { nodeType: 'CreditNode' } + ) + + ;(api as EventTarget).dispatchEvent( + new CustomEvent('execution_error', { + detail: { + exception_type: 'runtime_error', + exception_message: 'Failed' + } + }) + ) + expect(useExecutionErrorStore().isErrorOverlayOpen).toBe(true) + + mockSettingStore.get.mockReturnValue(false) + ;(api as EventTarget).dispatchEvent( + new CustomEvent('execution_error', { + detail: { + exception_type: 'runtime_error', + exception_message: 'Still failed' + } + }) + ) + expect(mockDialogService.showExecutionErrorDialog).toHaveBeenCalledWith({ + exception_type: 'runtime_error', + exception_message: 'Still failed' + }) + + ;(api as EventTarget).dispatchEvent( + new CustomEvent('execution_error', { + detail: {} + }) + ) + expect(mockDialogService.showExecutionErrorDialog).toHaveBeenCalledWith( + {} + ) + + ;(api as EventTarget).dispatchEvent( + new CustomEvent('b_preview_with_metadata', { + detail: { + blob: new Blob(), + displayNodeId: '', + jobId: 'job-1' + } + }) + ) + expect( + mockNodeOutputStore.revokePreviewsByExecutionId + ).not.toHaveBeenCalled() + + const objectUrl = 'blob:preview' + vi.spyOn(URL, 'createObjectURL').mockReturnValue(objectUrl) + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + ;(api as EventTarget).dispatchEvent( + new CustomEvent('b_preview_with_metadata', { + detail: { + blob: new Blob(), + displayNodeId: '7', + jobId: 'job-2' + } + }) + ) + expect(mockNodeOutputStore.revokePreviewsByExecutionId).toHaveBeenCalled() + expect( + mockNodeOutputStore.setNodePreviewsByExecutionId + ).toHaveBeenCalled() + + ;(api as EventTarget).dispatchEvent(new CustomEvent('feature_flags')) + expect(mockNodeReplacementStore.load).toHaveBeenCalled() + expect(mockCanvas.draw).toHaveBeenCalledWith(true, true) + }) + }) +}) diff --git a/src/scripts/app.ts b/src/scripts/app.ts index a635ee333ce..948d4826dc1 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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, diff --git a/src/stores/executionErrorStore.test.ts b/src/stores/executionErrorStore.test.ts index dbc0a012420..c0b8ed5adc0 100644 --- a/src/stores/executionErrorStore.test.ts +++ b/src/stores/executionErrorStore.test.ts @@ -44,6 +44,24 @@ describe('executionErrorStore — node error operations', () => { setActivePinia(createPinia()) }) + it('keeps an inline required input missing fixture', () => { + setActivePinia(createPinia()) + const nodeError = { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'prompt' } + } + ], + dependent_outputs: [], + class_type: 'CanaryNode' + } + + expect(nodeError.errors[0].type).toBe('required_input_missing') + }) + describe('clearSimpleNodeErrors', () => { it('does nothing if lastNodeErrors is null', () => { const store = useExecutionErrorStore() diff --git a/src/stores/subgraphNavigationStore.test.ts b/src/stores/subgraphNavigationStore.test.ts index f56fd467027..22220869d56 100644 --- a/src/stores/subgraphNavigationStore.test.ts +++ b/src/stores/subgraphNavigationStore.test.ts @@ -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({ @@ -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({ diff --git a/src/stores/subgraphNavigationStore.viewport.test.ts b/src/stores/subgraphNavigationStore.viewport.test.ts index 51dd745135c..b7f2647d8d9 100644 --- a/src/stores/subgraphNavigationStore.viewport.test.ts +++ b/src/stores/subgraphNavigationStore.viewport.test.ts @@ -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({ 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({ 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({ _nodes: [], nodes: [], subgraphs: new Map(), getNodeById: vi.fn() - } as Partial 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 as Subgraph + workflowStore.activeSubgraph = fromPartial(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({ + 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() diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts index 0d9c37a6724..bf8c6fe651c 100644 --- a/src/utils/litegraphUtil.ts +++ b/src/utils/litegraphUtil.ts @@ -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 } diff --git a/src/utils/vintageClipboard.test.ts b/src/utils/vintageClipboard.test.ts new file mode 100644 index 00000000000..cdeb9923e93 --- /dev/null +++ b/src/utils/vintageClipboard.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' + +function createNode(): LGraphNode { + return { id: 1 } as unknown as LGraphNode +} + +function createCanvas(): LGraphCanvas { + return { graph: null } as unknown as LGraphCanvas +} + +describe('vintageClipboard canary', () => { + it('uses hand-rolled litegraph mocks', () => { + const node = createNode() + const canvas = createCanvas() + + expect(node.id).toBe(1) + expect(canvas.graph).toBeNull() + }) +})