diff --git a/.github/workflows/ci-tests-unit.yaml b/.github/workflows/ci-tests-unit.yaml index 352eb8a49df..6f0b1767fbb 100644 --- a/.github/workflows/ci-tests-unit.yaml +++ b/.github/workflows/ci-tests-unit.yaml @@ -55,3 +55,6 @@ jobs: flags: unit token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false + + - name: Enforce critical coverage gate + run: pnpm test:coverage:critical diff --git a/.github/workflows/ci-website-e2e.yaml b/.github/workflows/ci-website-e2e.yaml index ea8e7f0592c..f86232ec034 100644 --- a/.github/workflows/ci-website-e2e.yaml +++ b/.github/workflows/ci-website-e2e.yaml @@ -67,7 +67,15 @@ jobs: - name: Deploy report to Cloudflare id: deploy - if: always() && !cancelled() + if: >- + ${{ + always() && + !cancelled() && + ( + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.fork == false + ) + }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/browser_tests/tests/cloud.spec.ts b/browser_tests/tests/cloud.spec.ts index 2ebacf7fe0b..72660c77a94 100644 --- a/browser_tests/tests/cloud.spec.ts +++ b/browser_tests/tests/cloud.spec.ts @@ -14,36 +14,44 @@ const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth' * routes and elements. */ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => { - test('cloud build redirects unauthenticated users to login', async ({ - page - }) => { - await page.goto(APP_URL) - // Cloud build has an auth guard that redirects to /cloud/login. - // This route only exists in the cloud distribution — it's tree-shaken - // in the OSS build. Its presence confirms the cloud build is active. - await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 }) - }) + test( + 'cloud build redirects unauthenticated users to login', + { tag: '@critical' }, + async ({ page }) => { + await page.goto(APP_URL) + // Cloud build has an auth guard that redirects to /cloud/login. + // This route only exists in the cloud distribution — it's tree-shaken + // in the OSS build. Its presence confirms the cloud build is active. + await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 }) + } + ) - test('preserves share auth attribution before redirecting logged-out users', async ({ - page - }) => { - await page.goto(new URL('/?share=abc', APP_URL).toString()) + test( + 'preserves share auth attribution before redirecting logged-out users', + { tag: '@critical' }, + async ({ page }) => { + await page.goto(new URL('/?share=abc', APP_URL).toString()) - await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 }) - await expect - .poll(() => - page.evaluate( - (key) => sessionStorage.getItem(key), - SHARE_AUTH_STORAGE_KEY + await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 }) + await expect + .poll(() => + page.evaluate( + (key) => sessionStorage.getItem(key), + SHARE_AUTH_STORAGE_KEY + ) ) - ) - .toBe(JSON.stringify({ share: 'abc' })) - }) + .toBe(JSON.stringify({ share: 'abc' })) + } + ) - test('cloud login page renders sign-in options', async ({ page }) => { - await page.goto(APP_URL) - await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 }) - // Verify cloud-specific login UI is rendered - await expect(page.getByRole('button', { name: /google/i })).toBeVisible() - }) + test( + 'cloud login page renders sign-in options', + { tag: '@critical' }, + async ({ page }) => { + await page.goto(APP_URL) + await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 }) + // Verify cloud-specific login UI is rendered + await expect(page.getByRole('button', { name: /google/i })).toBeVisible() + } + ) }) diff --git a/browser_tests/tests/execution.spec.ts b/browser_tests/tests/execution.spec.ts index a40499a811c..04e2f62e0be 100644 --- a/browser_tests/tests/execution.spec.ts +++ b/browser_tests/tests/execution.spec.ts @@ -97,34 +97,38 @@ test.describe( 'Execute to selected output nodes', { tag: ['@smoke', '@workflow'] }, () => { - test('Execute to selected output nodes', async ({ comfyPage }) => { - await comfyPage.workflow.loadWorkflow('execution/partial_execution') - const input = await comfyPage.nodeOps.getNodeRefById(3) - const output1 = await comfyPage.nodeOps.getNodeRefById(1) - const output2 = await comfyPage.nodeOps.getNodeRefById(4) - await expect - .poll(async () => (await input.getWidget(0)).getValue()) - .toBe('foo') - await expect - .poll(async () => (await output1.getWidget(0)).getValue()) - .toBe('') - await expect - .poll(async () => (await output2.getWidget(0)).getValue()) - .toBe('') - - await output1.click('title') - - await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes') - await expect - .poll(async () => (await input.getWidget(0)).getValue()) - .toBe('foo') - await expect - .poll(async () => (await output1.getWidget(0)).getValue()) - .toBe('foo') - await expect - .poll(async () => (await output2.getWidget(0)).getValue()) - .toBe('') - }) + test( + 'Execute to selected output nodes', + { tag: '@critical' }, + async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('execution/partial_execution') + const input = await comfyPage.nodeOps.getNodeRefById(3) + const output1 = await comfyPage.nodeOps.getNodeRefById(1) + const output2 = await comfyPage.nodeOps.getNodeRefById(4) + await expect + .poll(async () => (await input.getWidget(0)).getValue()) + .toBe('foo') + await expect + .poll(async () => (await output1.getWidget(0)).getValue()) + .toBe('') + await expect + .poll(async () => (await output2.getWidget(0)).getValue()) + .toBe('') + + await output1.click('title') + + await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes') + await expect + .poll(async () => (await input.getWidget(0)).getValue()) + .toBe('foo') + await expect + .poll(async () => (await output1.getWidget(0)).getValue()) + .toBe('foo') + await expect + .poll(async () => (await output2.getWidget(0)).getValue()) + .toBe('') + } + ) } ) diff --git a/browser_tests/tests/extensionAPI.spec.ts b/browser_tests/tests/extensionAPI.spec.ts index daee44b5015..223e286978f 100644 --- a/browser_tests/tests/extensionAPI.spec.ts +++ b/browser_tests/tests/extensionAPI.spec.ts @@ -13,33 +13,37 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' type TestSettingId = keyof Settings test.describe('Topbar commands', () => { - test('Should allow registering topbar commands', async ({ comfyPage }) => { - await comfyPage.page.evaluate(() => { - window.app!.registerExtension({ - name: 'TestExtension1', - commands: [ - { - id: 'foo', - label: 'foo-command', - function: () => { - window.foo = true + test( + 'Should allow registering topbar commands', + { tag: '@critical' }, + async ({ comfyPage }) => { + await comfyPage.page.evaluate(() => { + window.app!.registerExtension({ + name: 'TestExtension1', + commands: [ + { + id: 'foo', + label: 'foo-command', + function: () => { + window.foo = true + } } - } - ], - menuCommands: [ - { - path: ['ext'], - commands: ['foo'] - } - ] + ], + menuCommands: [ + { + path: ['ext'], + commands: ['foo'] + } + ] + }) }) - }) - await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command']) - await expect - .poll(() => comfyPage.page.evaluate(() => window.foo)) - .toBe(true) - }) + await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command']) + await expect + .poll(() => comfyPage.page.evaluate(() => window.foo)) + .toBe(true) + } + ) test('Should not allow register command defined in other extension', async ({ comfyPage diff --git a/browser_tests/tests/graph.spec.ts b/browser_tests/tests/graph.spec.ts index 1250a6252fd..042394d4b88 100644 --- a/browser_tests/tests/graph.spec.ts +++ b/browser_tests/tests/graph.spec.ts @@ -22,11 +22,15 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => { .toBe(1) }) - test('Validate workflow links', async ({ comfyPage }) => { - await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true) - await comfyPage.workflow.loadWorkflow('links/bad_link') - await expect(comfyPage.toast.visibleToasts).toHaveCount(2) - }) + test( + 'Validate workflow links', + { tag: '@critical' }, + async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true) + await comfyPage.workflow.loadWorkflow('links/bad_link') + await expect(comfyPage.toast.visibleToasts).toHaveCount(2) + } + ) // Regression: duplicate links with shifted target_slot (widget-to-input // conversion) caused the wrong link to survive during deduplication. diff --git a/browser_tests/tests/nodeSearchBoxV2.spec.ts b/browser_tests/tests/nodeSearchBoxV2.spec.ts index 7e014a859e7..3d18e13f29a 100644 --- a/browser_tests/tests/nodeSearchBoxV2.spec.ts +++ b/browser_tests/tests/nodeSearchBoxV2.spec.ts @@ -8,20 +8,24 @@ test.describe('Node search box V2', { tag: '@node' }, () => { await comfyPage.searchBoxV2.setup() }) - test('Can open search and add node', async ({ comfyPage }) => { - const { searchBoxV2 } = comfyPage - const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + test( + 'Can open search and add node', + { tag: '@critical' }, + async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() - await searchBoxV2.open() - await searchBoxV2.input.fill('KSampler') - await expect(searchBoxV2.results.first()).toBeVisible() + await searchBoxV2.open() + await searchBoxV2.input.fill('KSampler') + await expect(searchBoxV2.results.first()).toBeVisible() - await comfyPage.page.keyboard.press('Enter') - await expect(searchBoxV2.input).toBeHidden() - await expect - .poll(() => comfyPage.nodeOps.getGraphNodesCount()) - .toBe(initialCount + 1) - }) + await comfyPage.page.keyboard.press('Enter') + await expect(searchBoxV2.input).toBeHidden() + await expect + .poll(() => comfyPage.nodeOps.getGraphNodesCount()) + .toBe(initialCount + 1) + } + ) test('Can add first default result with Enter', async ({ comfyPage }) => { const { searchBoxV2 } = comfyPage diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts index c41fd6145ec..51a69d60bb1 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts @@ -33,19 +33,21 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { await cleanupFakeModel(comfyPage) }) - test('Should show missing models group in errors tab', async ({ - comfyPage - }) => { - await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models') + test( + 'Should show missing models group in errors tab', + { tag: '@critical' }, + async ({ comfyPage }) => { + await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models') - const missingModelsGroup = comfyPage.page.getByTestId( - TestIds.dialogs.missingModelsGroup - ) - await expect(missingModelsGroup).toBeVisible() - await expect( - missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage) - ).toHaveText(/\S/) - }) + const missingModelsGroup = comfyPage.page.getByTestId( + TestIds.dialogs.missingModelsGroup + ) + await expect(missingModelsGroup).toBeVisible() + await expect( + missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage) + ).toHaveText(/\S/) + } + ) test('Should display model name and metadata', async ({ comfyPage }) => { await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models') diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts index 1235da29e4e..f74270f8d3d 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts @@ -12,23 +12,25 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => { ) }) - test('Should show missing node pack card with guidance', async ({ - comfyPage - }) => { - await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes') - - const missingNodeGroup = comfyPage.page.getByTestId( - TestIds.dialogs.missingNodePacksGroup - ) - - await expect( - comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard) - ).toBeVisible() - await expect(missingNodeGroup).toBeVisible() - await expect( - missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage) - ).toHaveText(/\S/) - }) + test( + 'Should show missing node pack card with guidance', + { tag: '@critical' }, + async ({ comfyPage }) => { + await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes') + + const missingNodeGroup = comfyPage.page.getByTestId( + TestIds.dialogs.missingNodePacksGroup + ) + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard) + ).toBeVisible() + await expect(missingNodeGroup).toBeVisible() + await expect( + missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage) + ).toHaveText(/\S/) + } + ) test('Should show unknown pack node rows by default', async ({ comfyPage diff --git a/browser_tests/tests/queue/queueOverlay.spec.ts b/browser_tests/tests/queue/queueOverlay.spec.ts index 436929b07ea..636c636609b 100644 --- a/browser_tests/tests/queue/queueOverlay.spec.ts +++ b/browser_tests/tests/queue/queueOverlay.spec.ts @@ -54,13 +54,19 @@ test.describe('Queue overlay', () => { await comfyPage.setup() }) - test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => { - const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle) - await toggle.click() - - // Expanded overlay should show job items - await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible() - }) + test( + 'Toggle button opens expanded queue overlay', + { tag: '@critical' }, + async ({ comfyPage }) => { + const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle) + await toggle.click() + + // Expanded overlay should show job items + await expect( + comfyPage.page.locator('[data-job-id]').first() + ).toBeVisible() + } + ) test('Overlay shows filter tabs (All, Completed)', async ({ comfyPage }) => { const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle) diff --git a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts index bca40d62800..5774db8f7d4 100644 --- a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts +++ b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts @@ -129,7 +129,7 @@ async function expectPromotedWidgetsToResolveToInteriorNodes( test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => { test( 'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip', - { tag: ['@vue-nodes'] }, + { tag: ['@vue-nodes', '@critical'] }, async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow( 'subgraphs/subgraph-with-link-and-proxied-primitive' diff --git a/package.json b/package.json index ebb28861454..939e660170f 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,11 @@ "stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'", "test:browser": "pnpm exec playwright test", "test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser", + "test:browser:critical": "pnpm exec playwright test --project=chromium --grep @critical", + "test:browser:cloud-critical": "pnpm exec playwright test --project=cloud --grep @critical", "test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser", "test:coverage": "vitest run --coverage", + "test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage", "test:unit": "vitest run", "typecheck": "vue-tsc --noEmit", "typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json", diff --git a/src/base/common/async.test.ts b/src/base/common/async.test.ts new file mode 100644 index 00000000000..0ef650846ae --- /dev/null +++ b/src/base/common/async.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('runWhenGlobalIdle', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('falls back to a timeout when idle callbacks are unavailable', async () => { + vi.useFakeTimers() + vi.stubGlobal('requestIdleCallback', undefined) + vi.stubGlobal('cancelIdleCallback', undefined) + const { runWhenGlobalIdle } = await import('./async') + const runner = vi.fn() + + const disposable = runWhenGlobalIdle(runner) + await vi.runAllTimersAsync() + + expect(runner).toHaveBeenCalledOnce() + const deadline = runner.mock.calls[0][0] + expect(deadline.didTimeout).toBe(true) + expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0) + + disposable.dispose() + disposable.dispose() + }) + + it('cancels fallback idle work before it runs', async () => { + vi.useFakeTimers() + vi.stubGlobal('requestIdleCallback', undefined) + vi.stubGlobal('cancelIdleCallback', undefined) + const { runWhenGlobalIdle } = await import('./async') + const runner = vi.fn() + + runWhenGlobalIdle(runner).dispose() + await vi.runAllTimersAsync() + + expect(runner).not.toHaveBeenCalled() + }) + + it('uses native idle callbacks when available', async () => { + const requestIdleCallback = vi.fn(() => 42) + const cancelIdleCallback = vi.fn() + vi.stubGlobal('requestIdleCallback', requestIdleCallback) + vi.stubGlobal('cancelIdleCallback', cancelIdleCallback) + const { runWhenGlobalIdle } = await import('./async') + const runner = vi.fn() + + const disposable = runWhenGlobalIdle(runner, 250) + + expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 }) + + disposable.dispose() + disposable.dispose() + + expect(cancelIdleCallback).toHaveBeenCalledOnce() + expect(cancelIdleCallback).toHaveBeenCalledWith(42) + }) + + it('omits native idle timeout options when no timeout is supplied', async () => { + const requestIdleCallback = vi.fn( + (_cb: IdleRequestCallback, _options?: IdleRequestOptions) => 7 + ) + vi.stubGlobal('requestIdleCallback', requestIdleCallback) + vi.stubGlobal('cancelIdleCallback', vi.fn()) + const { runWhenGlobalIdle } = await import('./async') + const runner = vi.fn() + + runWhenGlobalIdle(runner) + + expect(requestIdleCallback).toHaveBeenCalledOnce() + expect(requestIdleCallback.mock.calls[0][0]).toBe(runner) + expect(requestIdleCallback.mock.calls[0][1]).toBeUndefined() + }) +}) diff --git a/src/base/credits/comfyCredits.test.ts b/src/base/credits/comfyCredits.test.ts index a2ef78a5371..7660db7cc3d 100644 --- a/src/base/credits/comfyCredits.test.ts +++ b/src/base/credits/comfyCredits.test.ts @@ -4,6 +4,7 @@ import { CREDITS_PER_USD, COMFY_CREDIT_RATE_CENTS, centsToCredits, + clampUsd, creditsToCents, creditsToUsd, formatCredits, @@ -43,4 +44,23 @@ describe('comfyCredits helpers', () => { expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00') expect(formatUsd({ value: 4.2, locale })).toBe('4.20') }) + + test('recovers from incompatible fraction digit bounds', () => { + // {min:3,max:1} collapses to one fraction digit ('12.3'); the default {2,2} + // would yield '12.35', so this distinguishes recovery from options ignored. + expect( + formatCredits({ + value: 12.345, + locale: 'en-US', + numberOptions: { minimumFractionDigits: 3, maximumFractionDigits: 1 } + }) + ).toBe('12.3') + }) + + test('clamps USD purchase values into the supported range', () => { + expect(clampUsd(Number.NaN)).toBe(0) + expect(clampUsd(-5)).toBe(1) + expect(clampUsd(42)).toBe(42) + expect(clampUsd(5000)).toBe(1000) + }) }) diff --git a/src/composables/canvas/useSelectionToolboxPosition.test.ts b/src/composables/canvas/useSelectionToolboxPosition.test.ts index d7ae382531e..3ad6a5f7459 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.test.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.test.ts @@ -34,17 +34,22 @@ describe('useSelectionToolboxPosition', () => { canvasStore = useCanvasStore() }) - function renderToolboxForSelection(item: Positionable) { + function renderToolboxForSelection( + items: Iterable, + state: Partial = {}, + ds: Partial = {} + ) { canvasStore.canvas = markRaw({ canvas: document.createElement('canvas'), ds: { - offset: [0, 0], - scale: 1 + offset: ds.offset ?? [0, 0], + scale: ds.scale ?? 1 }, - selectedItems: new Set([item]), + selectedItems: new Set(items), state: { draggingItems: false, - selectionChanged: true + selectionChanged: true, + ...state } } as Partial as LGraphCanvas) @@ -69,7 +74,7 @@ describe('useSelectionToolboxPosition', () => { group.pos = [100, 200] group.size = [160, 80] - const { toolbox, unmount } = renderToolboxForSelection(group) + const { toolbox, unmount } = renderToolboxForSelection([group]) expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px') unmount() @@ -81,11 +86,64 @@ describe('useSelectionToolboxPosition', () => { node.pos = [100, 200] node.size = [160, 80] - const { toolbox, unmount } = renderToolboxForSelection(node) + const { toolbox, unmount } = renderToolboxForSelection([node]) expect(toolbox.style.getPropertyValue('--tb-y')).toBe( `${190 - LiteGraph.NODE_TITLE_HEIGHT}px` ) unmount() }) + + it('does not set coordinates when selection is empty', () => { + const { toolbox, unmount } = renderToolboxForSelection([]) + + expect(toolbox.style.getPropertyValue('--tb-x')).toBe('') + expect(toolbox.style.getPropertyValue('--tb-y')).toBe('') + unmount() + }) + + it('does not set coordinates while selected items are being dragged', () => { + const group = new LGraphGroup('Group', 1) + group.pos = [100, 200] + group.size = [160, 80] + + const { toolbox, unmount } = renderToolboxForSelection([group], { + draggingItems: true + }) + + expect(toolbox.style.getPropertyValue('--tb-x')).toBe('') + expect(toolbox.style.getPropertyValue('--tb-y')).toBe('') + unmount() + }) + + it('positions multiple selected items from their union bounds', () => { + const first = new LGraphGroup('First', 1) + first.pos = [100, 200] + first.size = [100, 40] + const second = new LGraphGroup('Second', 2) + second.pos = [300, 260] + second.size = [50, 40] + + const { toolbox, unmount } = renderToolboxForSelection([first, second]) + + expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px') + expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px') + unmount() + }) + + it('applies canvas scale and offset to screen coordinates', () => { + const group = new LGraphGroup('Group', 1) + group.pos = [100, 200] + group.size = [100, 40] + + const { toolbox, unmount } = renderToolboxForSelection( + [group], + {}, + { offset: [10, 20], scale: 2 } + ) + + expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px') + expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px') + unmount() + }) }) diff --git a/src/composables/graph/useGroupMenuOptions.test.ts b/src/composables/graph/useGroupMenuOptions.test.ts new file mode 100644 index 00000000000..26afea56cd8 --- /dev/null +++ b/src/composables/graph/useGroupMenuOptions.test.ts @@ -0,0 +1,217 @@ +import type * as VueI18n from 'vue-i18n' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LGraphGroup } from '@/lib/litegraph/src/litegraph' +import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' +import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions' + +const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } = + vi.hoisted(() => ({ + canvas: { setDirty: vi.fn() }, + captureCanvasState: vi.fn(), + isLightTheme: { value: false }, + refreshCanvas: vi.fn(), + settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record< + string, + unknown + > + })) + +vi.mock('vue-i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }) +})) +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ get: (k: string) => settings[k] }) +})) +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + activeWorkflow: { changeTracker: { captureCanvasState } } + }) +})) +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas }) +})) +vi.mock('@/composables/graph/useCanvasRefresh', () => ({ + useCanvasRefresh: () => ({ refreshCanvas }) +})) +vi.mock('@/composables/graph/useNodeCustomization', () => ({ + useNodeCustomization: () => ({ + shapeOptions: [{ value: 1, localizedName: 'Box' }], + colorOptions: [ + { value: { dark: '#111', light: '#eee' }, localizedName: 'Red' } + ], + isLightTheme + }) +})) + +function group(over: Record = {}): LGraphGroup { + return { + recomputeInsideNodes: vi.fn(), + resizeTo: vi.fn(), + children: [], + graph: { change: vi.fn() }, + nodes: [], + ...over + } as unknown as LGraphGroup +} + +beforeEach(() => { + canvas.setDirty.mockReset() + captureCanvasState.mockReset() + isLightTheme.value = false + refreshCanvas.mockReset() +}) + +describe('useGroupMenuOptions', () => { + it('fits a group to its nodes, resizing with the configured padding', () => { + const g = group() + useGroupMenuOptions().getFitGroupToNodesOption(g).action?.() + + expect(g.recomputeInsideNodes).toHaveBeenCalled() + expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10) + expect(canvas.setDirty).toHaveBeenCalledWith(true, true) + expect(captureCanvasState).toHaveBeenCalled() + }) + + it('aborts the fit action when recompute throws', () => { + const g = group({ + recomputeInsideNodes: vi.fn(() => { + throw new Error('boom') + }) + }) + useGroupMenuOptions().getFitGroupToNodesOption(g).action?.() + + expect(g.resizeTo).not.toHaveBeenCalled() + }) + + it('applies a shape to all group nodes via the shape submenu', () => { + const node = { shape: 0, mode: LGraphEventMode.ALWAYS } + const bump = vi.fn() + const option = useGroupMenuOptions().getGroupShapeOptions( + group({ nodes: [node] }), + bump + ) + option.submenu?.[0].action?.() + + expect(node.shape).toBe(1) + expect(refreshCanvas).toHaveBeenCalled() + expect(bump).toHaveBeenCalled() + }) + + it('handles shape actions when a group has no nodes array', () => { + const bump = vi.fn() + useGroupMenuOptions() + .getGroupShapeOptions(group({ nodes: undefined }), bump) + .submenu?.[0].action?.() + + expect(refreshCanvas).toHaveBeenCalled() + expect(bump).toHaveBeenCalled() + }) + + it('applies a color to the group via the color submenu (dark theme)', () => { + const g = group() + const bump = vi.fn() + useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.() + + expect((g as unknown as { color: string }).color).toBe('#111') + expect(bump).toHaveBeenCalled() + }) + + it('applies a light-theme color to the group via the color submenu', () => { + const g = group() + const bump = vi.fn() + isLightTheme.value = true + useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.() + + expect((g as unknown as { color: string }).color).toBe('#eee') + expect(bump).toHaveBeenCalled() + }) + + it('returns no mode options for an empty group', () => { + expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual( + [] + ) + }) + + it('returns no mode options when a group has no nodes array', () => { + expect( + useGroupMenuOptions().getGroupModeOptions( + group({ nodes: undefined }), + vi.fn() + ) + ).toEqual([]) + }) + + it('returns no mode options when recomputing group nodes fails', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const options = useGroupMenuOptions().getGroupModeOptions( + group({ + recomputeInsideNodes: vi.fn(() => { + throw new Error('boom') + }) + }), + vi.fn() + ) + + expect(options).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to recompute nodes in group for mode options:', + expect.any(Error) + ) + + warnSpy.mockRestore() + }) + + it('builds mode options for uniform nodes and applies the new mode', () => { + const node = { shape: 0, mode: LGraphEventMode.ALWAYS } + const bump = vi.fn() + const options = useGroupMenuOptions().getGroupModeOptions( + group({ nodes: [node] }), + bump + ) + + expect(options.length).toBeGreaterThan(0) + options[0].action?.() + expect(node.mode).not.toBe(LGraphEventMode.ALWAYS) + expect(canvas.setDirty).toHaveBeenCalledWith(true, true) + expect(bump).toHaveBeenCalled() + }) + + it('offers two alternate modes when all nodes are NEVER', () => { + const options = useGroupMenuOptions().getGroupModeOptions( + group({ nodes: [{ mode: LGraphEventMode.NEVER }] }), + vi.fn() + ) + expect(options).toHaveLength(2) + }) + + it('offers two alternate modes when all nodes are BYPASS', () => { + const options = useGroupMenuOptions().getGroupModeOptions( + group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }), + vi.fn() + ) + expect(options).toHaveLength(2) + }) + + it('offers all three modes when nodes have mixed modes', () => { + const options = useGroupMenuOptions().getGroupModeOptions( + group({ + nodes: [ + { mode: LGraphEventMode.ALWAYS }, + { mode: LGraphEventMode.NEVER } + ] + }), + vi.fn() + ) + expect(options).toHaveLength(3) + }) + + it('offers all three modes when the uniform mode is unknown', () => { + const options = useGroupMenuOptions().getGroupModeOptions( + group({ nodes: [{ mode: 999 }] }), + vi.fn() + ) + expect(options).toHaveLength(3) + }) +}) diff --git a/src/composables/graph/useImageMenuOptions.test.ts b/src/composables/graph/useImageMenuOptions.test.ts index 6bf02f3f761..8e6f9561bf8 100644 --- a/src/composables/graph/useImageMenuOptions.test.ts +++ b/src/composables/graph/useImageMenuOptions.test.ts @@ -1,6 +1,7 @@ import { fromPartial } from '@total-typescript/shoehorn' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' import { useImageMenuOptions } from './useImageMenuOptions' @@ -19,6 +20,11 @@ vi.mock('@/stores/commandStore', () => ({ useCommandStore: () => ({ execute: vi.fn() }) })) +vi.mock('@/base/common/downloadUtil', () => ({ + downloadFile: vi.fn(), + openFileInNewTab: vi.fn() +})) + function mockClipboard(clipboard: Partial | undefined) { Object.defineProperty(navigator, 'clipboard', { value: clipboard, @@ -27,6 +33,15 @@ function mockClipboard(clipboard: Partial | undefined) { }) } +function stubClipboardItem() { + vi.stubGlobal( + 'ClipboardItem', + class ClipboardItemStub { + constructor(public readonly items: Record) {} + } + ) +} + function createImageNode( overrides: Partial | Record = {} ): LGraphNode { @@ -45,8 +60,13 @@ function createImageNode( } describe('useImageMenuOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('getImageMenuOptions', () => { @@ -182,4 +202,147 @@ describe('useImageMenuOptions', () => { expect(node.pasteFiles).not.toHaveBeenCalled() }) }) + + describe('image actions', () => { + it('opens the selected image without preview query params', () => { + const node = createImageNode() + node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar' + + const { getImageMenuOptions } = useImageMenuOptions() + const openOption = getImageMenuOptions(node).find( + (o) => o.label === 'Open Image' + ) + openOption?.action?.() + + expect(openFileInNewTab).toHaveBeenCalledWith( + 'http://localhost/test.png?foo=bar' + ) + }) + + it('saves the selected image without preview query params', () => { + const node = createImageNode() + node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar' + + const { getImageMenuOptions } = useImageMenuOptions() + const saveOption = getImageMenuOptions(node).find( + (o) => o.label === 'Save Image' + ) + saveOption?.action?.() + + expect(downloadFile).toHaveBeenCalledWith( + 'http://localhost/test.png?foo=bar' + ) + }) + + it('does not open or save when the active image is missing', () => { + const node = createImageNode({ imageIndex: 1 }) + + const { getImageMenuOptions } = useImageMenuOptions() + const options = getImageMenuOptions(node) + const openOption = options.find((o) => o.label === 'Open Image') + const saveOption = options.find((o) => o.label === 'Save Image') + + expect(openOption?.action).toEqual(expect.any(Function)) + expect(saveOption?.action).toEqual(expect.any(Function)) + + openOption?.action?.() + saveOption?.action?.() + + expect(openFileInNewTab).not.toHaveBeenCalled() + expect(downloadFile).not.toHaveBeenCalled() + }) + + it('logs save failures for invalid image URLs', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const node = createImageNode() + Object.defineProperty(node.imgs![0], 'src', { + value: 'http://[', + configurable: true + }) + + const { getImageMenuOptions } = useImageMenuOptions() + getImageMenuOptions(node) + .find((o) => o.label === 'Save Image') + ?.action?.() + + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to save image:', + expect.any(TypeError) + ) + expect(downloadFile).not.toHaveBeenCalled() + }) + + it('copies the selected image to clipboard', async () => { + const node = createImageNode() + const drawImage = vi.fn() + const write = vi.fn().mockResolvedValue(undefined) + stubClipboardItem() + mockClipboard(fromPartial({ write })) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( + (() => + fromPartial({ + drawImage + })) as unknown as HTMLCanvasElement['getContext'] + ) + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation( + (callback: BlobCallback) => { + callback(new Blob(['image'], { type: 'image/png' })) + } + ) + + const { getImageMenuOptions } = useImageMenuOptions() + await getImageMenuOptions(node) + .find((o) => o.label === 'Copy Image') + ?.action?.() + + expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0) + expect(write).toHaveBeenCalledWith([ + expect.objectContaining({ + items: { 'image/png': expect.any(Blob) } + }) + ]) + }) + + it('does not copy when canvas context is unavailable', async () => { + const node = createImageNode() + const write = vi.fn() + mockClipboard(fromPartial({ write })) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( + (() => null) as HTMLCanvasElement['getContext'] + ) + + const { getImageMenuOptions } = useImageMenuOptions() + await getImageMenuOptions(node) + .find((o) => o.label === 'Copy Image') + ?.action?.() + + expect(write).not.toHaveBeenCalled() + }) + + it('does not copy when canvas blob creation fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const node = createImageNode() + const write = vi.fn() + mockClipboard(fromPartial({ write })) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( + (() => + fromPartial({ + drawImage: vi.fn() + })) as unknown as HTMLCanvasElement['getContext'] + ) + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation( + (callback: BlobCallback) => { + callback(null) + } + ) + + const { getImageMenuOptions } = useImageMenuOptions() + await getImageMenuOptions(node) + .find((o) => o.label === 'Copy Image') + ?.action?.() + + expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob') + expect(write).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/composables/graph/useMoreOptionsMenu.test.ts b/src/composables/graph/useMoreOptionsMenu.test.ts new file mode 100644 index 00000000000..7ae28d6846f --- /dev/null +++ b/src/composables/graph/useMoreOptionsMenu.test.ts @@ -0,0 +1,294 @@ +import { ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LGraphGroup } from '@/lib/litegraph/src/litegraph' +import { + isNodeOptionsOpen, + registerNodeOptionsInstance, + showNodeOptions, + toggleNodeOptions, + useMoreOptionsMenu +} from '@/composables/graph/useMoreOptionsMenu' + +const { + canvasState, + extraWidgetOptions, + imageOptions, + nodeMenu, + selectionMenu, + selectionState +} = vi.hoisted(() => ({ + canvasState: { + canvas: undefined as + | undefined + | { + getNodeMenuOptions: ReturnType + } + }, + extraWidgetOptions: { + value: [] as Array<{ content: string; callback?: () => void }> + }, + imageOptions: { + value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }> + }, + nodeMenu: { + visualOptions: { + value: [] as Array<{ + label: string + hasSubmenu?: boolean + submenu?: Array<{ label: string; action: () => void }> + }> + } + }, + selectionMenu: { + basicOptions: { value: [{ label: 'Copy' }] }, + multipleOptions: { value: [{ label: 'Align' }] }, + subgraphOptions: { value: [] as Array<{ label: string }> } + }, + selectionState: { + selectedItems: { value: [] as unknown[] }, + selectedNodes: { value: [] as unknown[] }, + canOpenNodeInfo: { value: false }, + openNodeInfo: vi.fn(() => true), + hasSubgraphs: { value: false }, + hasImageNode: { value: false }, + hasOutputNodesSelected: { value: false }, + hasMultipleSelection: { value: false }, + computeSelectionFlags: vi.fn(() => ({ + collapsed: false, + pinned: false + })) + } +})) + +vi.mock('@/composables/graph/useSelectionState', () => ({ + useSelectionState: () => selectionState +})) +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => canvasState +})) +vi.mock('@/services/litegraphService', () => ({ + getExtraOptionsForWidget: () => extraWidgetOptions.value +})) +vi.mock('@/composables/graph/useImageMenuOptions', () => ({ + useImageMenuOptions: () => ({ + getImageMenuOptions: () => imageOptions.value + }) +})) +vi.mock('@/composables/graph/useNodeMenuOptions', () => ({ + useNodeMenuOptions: () => ({ + getNodeInfoOption: (openNodeInfo: () => boolean) => ({ + label: 'Node Info', + action: openNodeInfo + }), + getNodeVisualOptions: () => nodeMenu.visualOptions.value, + getPinOption: () => ({ label: 'Pin' }), + getBypassOption: () => ({ label: 'Bypass' }), + getRunBranchOption: () => ({ label: 'Run Branch' }) + }) +})) +vi.mock('@/composables/graph/useGroupMenuOptions', () => ({ + useGroupMenuOptions: () => ({ + getFitGroupToNodesOption: () => ({ label: 'Fit' }), + getGroupColorOptions: () => ({ label: 'Group Color' }), + getGroupModeOptions: () => [{ label: 'Group Mode' }] + }) +})) +vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({ + useSelectionMenuOptions: () => ({ + getBasicSelectionOptions: () => selectionMenu.basicOptions.value, + getMultipleNodesOptions: () => selectionMenu.multipleOptions.value, + getSubgraphOptions: () => selectionMenu.subgraphOptions.value + }) +})) + +beforeEach(() => { + vi.clearAllMocks() + registerNodeOptionsInstance(null) + canvasState.canvas = undefined + extraWidgetOptions.value = [] + imageOptions.value = [] + nodeMenu.visualOptions.value = [] + selectionMenu.basicOptions.value = [{ label: 'Copy' }] + selectionMenu.multipleOptions.value = [{ label: 'Align' }] + selectionMenu.subgraphOptions.value = [] + selectionState.selectedItems.value = [] + selectionState.selectedNodes.value = [] + selectionState.canOpenNodeInfo.value = false + selectionState.hasSubgraphs.value = false + selectionState.hasImageNode.value = false + selectionState.hasOutputNodesSelected.value = false + selectionState.hasMultipleSelection.value = false + selectionState.computeSelectionFlags.mockReturnValue({ + collapsed: false, + pinned: false + }) +}) + +function labels() { + return useMoreOptionsMenu() + .menuOptions.value.map((o) => o.label) + .filter(Boolean) +} + +describe('node options popover instance', () => { + it('reports closed when no instance is registered', () => { + expect(isNodeOptionsOpen()).toBe(false) + }) + + it('reflects the registered instance open state and forwards toggle/show', () => { + const toggle = vi.fn() + const show = vi.fn() + registerNodeOptionsInstance({ + toggle, + show, + hide: vi.fn(), + isOpen: ref(true) + }) + + expect(isNodeOptionsOpen()).toBe(true) + toggleNodeOptions(new Event('click')) + showNodeOptions(new MouseEvent('contextmenu')) + expect(toggle).toHaveBeenCalled() + expect(show).toHaveBeenCalled() + }) +}) + +describe('useMoreOptionsMenu', () => { + it('assembles a non-empty menu for a single selected node', () => { + const node = { id: 1, widgets: [] } + selectionState.selectedItems.value = [node] + selectionState.selectedNodes.value = [node] + + expect(labels()).toContain('Copy') + expect(labels()).toContain('Pin') + }) + + it('includes run-branch and multiple-node options for output selections', () => { + const nodes = [ + { id: 1, widgets: [] }, + { id: 2, widgets: [] } + ] + selectionState.selectedItems.value = nodes + selectionState.selectedNodes.value = nodes + selectionState.hasOutputNodesSelected.value = true + selectionState.hasMultipleSelection.value = true + + const menuLabels = labels() + expect(menuLabels).toContain('Run Branch') + expect(menuLabels).toContain('Align') + }) + + it('recomputes menu flags after a manual bump', () => { + const { bump, menuOptions } = useMoreOptionsMenu() + void menuOptions.value + expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1) + + bump() + void menuOptions.value + expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2) + }) + + it('assembles group-context options for a single selected group', () => { + const group = new LGraphGroup('Group') + selectionState.selectedItems.value = [group] + selectionState.selectedNodes.value = [] + + const menuLabels = labels() + expect(menuLabels).toContain('Group Mode') + expect(menuLabels).toContain('Fit') + expect(menuLabels).toContain('Group Color') + }) + + it('includes node info and visual options for a single node', () => { + const node = { id: 1, widgets: [] } + selectionState.selectedItems.value = [node] + selectionState.selectedNodes.value = [node] + selectionState.canOpenNodeInfo.value = true + nodeMenu.visualOptions.value = [ + { label: 'Minimize Node' }, + { label: 'Shape', hasSubmenu: true, submenu: [] }, + { label: 'Color', hasSubmenu: true, submenu: [] } + ] + + const menu = useMoreOptionsMenu().menuOptions.value + expect(menu.map((o) => o.label)).toEqual( + expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color']) + ) + menu.find((o) => o.label === 'Node Info')?.action?.() + expect(selectionState.openNodeInfo).toHaveBeenCalled() + }) + + it('returns only entries that have populated submenus', () => { + const node = { id: 1, widgets: [] } + selectionState.selectedItems.value = [node] + selectionState.selectedNodes.value = [node] + nodeMenu.visualOptions.value = [ + { label: 'Minimize Node' }, + { + label: 'Shape', + hasSubmenu: true, + submenu: [{ label: 'Box', action: vi.fn() }] + }, + { label: 'Color', hasSubmenu: true } + ] + + expect( + useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label) + ).toEqual(['Shape']) + }) + + it('includes image menu options for a selected image node', () => { + const node = { id: 1, widgets: [] } + selectionState.selectedItems.value = [node] + selectionState.selectedNodes.value = [node] + selectionState.hasImageNode.value = true + imageOptions.value = [{ label: 'Open Image' }] + + expect(labels()).toContain('Open Image') + }) + + it('merges LiteGraph menu options for a single selected node', () => { + const node = { id: 1, widgets: [] } + const getNodeMenuOptions = vi.fn(() => [ + { content: 'Extension Action', callback: vi.fn() } + ]) + selectionState.selectedItems.value = [node] + selectionState.selectedNodes.value = [node] + canvasState.canvas = { getNodeMenuOptions } + + expect(labels()).toContain('Extension Action') + expect(getNodeMenuOptions).toHaveBeenCalledWith(node) + }) + + it('keeps Vue options when LiteGraph menu construction throws', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const node = { id: 1, widgets: [] } + selectionState.selectedItems.value = [node] + selectionState.selectedNodes.value = [node] + canvasState.canvas = { + getNodeMenuOptions: vi.fn(() => { + throw new Error('boom') + }) + } + + expect(labels()).toContain('Copy') + expect(errorSpy).toHaveBeenCalledWith( + 'Error getting LiteGraph menu items:', + expect.any(Error) + ) + + errorSpy.mockRestore() + }) + + it('adds hovered widget options to the selected node menu', () => { + const node = { id: 1, widgets: [{ name: 'image' }] } + selectionState.selectedItems.value = [node] + selectionState.selectedNodes.value = [node] + extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }] + + showNodeOptions(new MouseEvent('contextmenu'), 'image') + + expect(labels()).toContain('Widget Extra') + }) +}) diff --git a/src/composables/graph/useNodeCustomization.test.ts b/src/composables/graph/useNodeCustomization.test.ts new file mode 100644 index 00000000000..f5d5d70f3f1 --- /dev/null +++ b/src/composables/graph/useNodeCustomization.test.ts @@ -0,0 +1,175 @@ +import type * as VueI18n from 'vue-i18n' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LGraphNode } from '@/lib/litegraph/src/litegraph' + +import { useNodeCustomization } from '@/composables/graph/useNodeCustomization' + +const { selection, refreshCanvas, palette } = vi.hoisted(() => ({ + selection: { items: [] as unknown[] }, + refreshCanvas: vi.fn(), + palette: { light_theme: false } +})) + +vi.mock('vue-i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ + get selectedItems() { + return selection.items + } + }) +})) + +vi.mock('@/stores/workspace/colorPaletteStore', () => ({ + useColorPaletteStore: () => ({ + get completedActivePalette() { + return { light_theme: palette.light_theme } + } + }) +})) + +vi.mock('@/composables/graph/useCanvasRefresh', () => ({ + useCanvasRefresh: () => ({ refreshCanvas }) +})) + +function colorable(bgcolor?: string) { + return { + setColorOption: vi.fn(), + getColorOption: () => (bgcolor ? { bgcolor } : null) + } +} + +beforeEach(() => { + selection.items = [] + refreshCanvas.mockReset() + palette.light_theme = false +}) + +describe('useNodeCustomization', () => { + it('exposes color and shape option lists', () => { + const { colorOptions, shapeOptions } = useNodeCustomization() + expect(colorOptions.length).toBeGreaterThan(1) + expect(shapeOptions.length).toBeGreaterThan(0) + }) + + it('reflects the active palette light-theme flag', () => { + palette.light_theme = true + expect(useNodeCustomization().isLightTheme.value).toBe(true) + }) + + it('clears color on all colorable items for the no-color option', () => { + const item = colorable() + selection.items = [item] + useNodeCustomization().applyColor(null) + + expect(item.setColorOption).toHaveBeenCalledWith(null) + expect(refreshCanvas).toHaveBeenCalled() + }) + + it('applies a named color option to colorable items', () => { + const item = colorable() + selection.items = [item] + const { colorOptions, applyColor } = useNodeCustomization() + const named = colorOptions.at(-1)! + + applyColor(named) + + expect(item.setColorOption).toHaveBeenCalledTimes(1) + expect(item.setColorOption.mock.calls[0][0]).not.toBeNull() + }) + + it('skips non-colorable items when applying colors', () => { + const item = colorable() + selection.items = [{}, item] + + useNodeCustomization().applyColor(null) + + expect(item.setColorOption).toHaveBeenCalledWith(null) + expect(refreshCanvas).toHaveBeenCalled() + }) + + it('returns null current color for an empty selection', () => { + expect(useNodeCustomization().getCurrentColor()).toBeNull() + }) + + it('returns null current color when no selected item is colorable', () => { + selection.items = [{}] + expect(useNodeCustomization().getCurrentColor()).toBeNull() + }) + + it('reports a recognized current color', () => { + const { colorOptions, getCurrentColor } = useNodeCustomization() + const named = colorOptions.at(-1)! + selection.items = [colorable(named.value.dark)] + + expect(getCurrentColor()?.name).toBe(named.name) + }) + + it('falls back to the no-color option for an unrecognized current color', () => { + selection.items = [colorable('#not-a-known-color')] + const result = useNodeCustomization().getCurrentColor() + expect(result?.name).toBe('noColor') + }) + + it('no-ops shape changes when no graph nodes are selected', () => { + selection.items = [colorable()] + const { applyShape, shapeOptions } = useNodeCustomization() + applyShape(shapeOptions[0]) + expect(refreshCanvas).not.toHaveBeenCalled() + }) + + it('returns null current shape with no nodes selected', () => { + expect(useNodeCustomization().getCurrentShape()).toBeNull() + }) + + it('applies a shape to selected graph nodes and refreshes', () => { + const node = new LGraphNode('Test') + selection.items = [node] + const { applyShape, shapeOptions } = useNodeCustomization() + const target = shapeOptions[0] + + applyShape(target) + + expect(node.shape).toBe(target.value) + expect(refreshCanvas).toHaveBeenCalled() + }) + + it('reports the current shape of a selected node', () => { + const node = new LGraphNode('Test') + const { shapeOptions, getCurrentShape } = useNodeCustomization() + node.shape = shapeOptions[0].value + selection.items = [node] + + expect(getCurrentShape()?.value).toBe(shapeOptions[0].value) + }) + + it('uses the default shape when a selected node has no shape', () => { + const node = new LGraphNode('Test') + Object.defineProperty(node, 'shape', { + value: undefined, + writable: true, + configurable: true + }) + const { shapeOptions, getCurrentShape } = useNodeCustomization() + selection.items = [node] + + expect(getCurrentShape()?.value).toBe(shapeOptions[0].value) + }) + + it('falls back to the default shape for an unknown node shape', () => { + const node = new LGraphNode('Test') + Object.defineProperty(node, 'shape', { + value: 999, + writable: true, + configurable: true + }) + const { shapeOptions, getCurrentShape } = useNodeCustomization() + selection.items = [node] + + expect(getCurrentShape()?.value).toBe(shapeOptions[0].value) + }) +}) diff --git a/src/composables/graph/useNodeMenuOptions.test.ts b/src/composables/graph/useNodeMenuOptions.test.ts index a6ab22bb1eb..973ba7df5b6 100644 --- a/src/composables/graph/useNodeMenuOptions.test.ts +++ b/src/composables/graph/useNodeMenuOptions.test.ts @@ -10,30 +10,43 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { toNodeId } from '@/types/nodeId' -// canvasStore transitively imports the app singleton; stub it so the real -// ComfyApp module never loads during these unit tests. +const { actions, customization } = vi.hoisted(() => ({ + actions: { + adjustNodeSize: vi.fn(), + toggleNodeCollapse: vi.fn(), + toggleNodePin: vi.fn(), + toggleNodeBypass: vi.fn(), + runBranch: vi.fn() + }, + customization: { + shapeOptions: [] as Array<{ localizedName: string; value: string }>, + colorOptions: [] as Array<{ + name: string + localizedName: string + value: { dark: string; light: string } + }>, + applyShape: vi.fn(), + applyColor: vi.fn(), + isLightTheme: { value: false } + } +})) + vi.mock('@/scripts/app', () => ({ app: { canvas: { selected_nodes: null } } })) vi.mock('@/composables/graph/useNodeCustomization', () => ({ useNodeCustomization: () => ({ - shapeOptions: [], - applyShape: vi.fn(), - applyColor: vi.fn(), - colorOptions: [], - isLightTheme: { value: false } + shapeOptions: customization.shapeOptions, + applyShape: customization.applyShape, + applyColor: customization.applyColor, + colorOptions: customization.colorOptions, + isLightTheme: customization.isLightTheme }) })) vi.mock('@/composables/graph/useSelectedNodeActions', () => ({ - useSelectedNodeActions: () => ({ - adjustNodeSize: vi.fn(), - toggleNodeCollapse: vi.fn(), - toggleNodePin: vi.fn(), - toggleNodeBypass: vi.fn(), - runBranch: vi.fn() - }) + useSelectedNodeActions: () => actions })) const i18n = createI18n({ @@ -69,9 +82,29 @@ const getBypassLabel = (selected: LGraphNode[]): string => { return label } -describe('useNodeMenuOptions.getBypassOption', () => { +function readNodeMenuOptions( + read: (options: ReturnType) => T +): T { + const unread = Symbol('unread') + const result: { value: T | typeof unread } = { value: unread } + const Wrapper = defineComponent({ + setup() { + result.value = read(useNodeMenuOptions()) + return () => null + } + }) + render(Wrapper, { global: { plugins: [i18n] } }) + if (result.value === unread) throw new Error('Composable was not read') + return result.value +} + +describe('useNodeMenuOptions', () => { beforeEach(() => { + vi.clearAllMocks() setActivePinia(createPinia()) + customization.shapeOptions = [] + customization.colorOptions = [] + customization.isLightTheme.value = false }) it('labels as "Bypass" when no node is bypassed', () => { @@ -97,4 +130,109 @@ describe('useNodeMenuOptions.getBypassOption', () => { ]) ).toBe('contextMenu.Bypass') }) + + it('labels visual node options from the collapsed state and bumps after action', () => { + const expandBump = vi.fn() + const expand = readNodeMenuOptions( + ({ getNodeVisualOptions }) => + getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0] + ) + expect(expand).toMatchObject({ + label: 'contextMenu.Expand Node', + icon: 'icon-[lucide--maximize-2]' + }) + expand.action?.() + expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1) + expect(expandBump).toHaveBeenCalledTimes(1) + + const minimize = readNodeMenuOptions( + ({ getNodeVisualOptions }) => + getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0] + ) + expect(minimize).toMatchObject({ + label: 'contextMenu.Minimize Node', + icon: 'icon-[lucide--minimize-2]' + }) + }) + + it('labels pin options from the pinned state and bumps after action', () => { + const bump = vi.fn() + const unpin = readNodeMenuOptions(({ getPinOption }) => + getPinOption({ collapsed: false, pinned: true }, bump) + ) + expect(unpin).toMatchObject({ + label: 'contextMenu.Unpin', + icon: 'icon-[lucide--pin-off]' + }) + unpin.action?.() + expect(actions.toggleNodePin).toHaveBeenCalledTimes(1) + expect(bump).toHaveBeenCalledTimes(1) + + const pin = readNodeMenuOptions(({ getPinOption }) => + getPinOption({ collapsed: false, pinned: false }, vi.fn()) + ) + expect(pin).toMatchObject({ + label: 'contextMenu.Pin', + icon: 'icon-[lucide--pin]' + }) + }) + + it('builds shape and color submenus and applies selected values', () => { + customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }] + customization.colorOptions = [ + { + name: 'noColor', + localizedName: 'No Color', + value: { dark: '#000', light: '#fff' } + }, + { + name: 'red', + localizedName: 'Red', + value: { dark: '#111', light: '#eee' } + } + ] + + const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({ + visualOptions: options.getNodeVisualOptions( + { collapsed: false, pinned: false }, + vi.fn() + ), + colorSubmenu: options.colorSubmenu.value + })) + + expect(visualOptions[1].submenu).toEqual([ + expect.objectContaining({ label: 'Box' }) + ]) + visualOptions[1].submenu?.[0].action() + expect(customization.applyShape).toHaveBeenCalledWith( + customization.shapeOptions[0] + ) + + expect(colorSubmenu).toEqual([ + expect.objectContaining({ label: 'No Color', color: '#000' }), + expect.objectContaining({ label: 'Red', color: '#111' }) + ]) + colorSubmenu[0].action() + colorSubmenu[1].action() + expect(customization.applyColor).toHaveBeenNthCalledWith(1, null) + expect(customization.applyColor).toHaveBeenNthCalledWith( + 2, + customization.colorOptions[1] + ) + }) + + it('uses light-theme colors for the color submenu', () => { + customization.isLightTheme.value = true + customization.colorOptions = [ + { + name: 'red', + localizedName: 'Red', + value: { dark: '#111', light: '#eee' } + } + ] + + expect( + readNodeMenuOptions((options) => options.colorSubmenu.value[0].color) + ).toBe('#eee') + }) }) diff --git a/src/composables/graph/useSelectionOperations.test.ts b/src/composables/graph/useSelectionOperations.test.ts new file mode 100644 index 00000000000..185f232dd1c --- /dev/null +++ b/src/composables/graph/useSelectionOperations.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useSelectionOperations } from '@/composables/graph/useSelectionOperations' + +const { + canvas, + toastAdd, + captureCanvasState, + updateSelectedItems, + prompt, + titleEditor, + store +} = vi.hoisted(() => ({ + canvas: { + selectedItems: new Set(), + copyToClipboard: vi.fn(), + pasteFromClipboard: vi.fn(), + deleteSelected: vi.fn(), + setDirty: vi.fn() + }, + toastAdd: vi.fn(), + captureCanvasState: vi.fn(), + updateSelectedItems: vi.fn(), + prompt: vi.fn(), + titleEditor: { titleEditorTarget: null as unknown }, + store: { selectedItems: [] as unknown[] } +})) + +vi.mock('@/scripts/app', () => ({ app: { canvas } })) +vi.mock('@/i18n', () => ({ t: (key: string) => key })) +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ add: toastAdd }) +})) +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + activeWorkflow: { changeTracker: { captureCanvasState } } + }) +})) +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ + updateSelectedItems, + get selectedItems() { + return store.selectedItems + } + }), + useTitleEditorStore: () => titleEditor +})) +vi.mock('@/services/dialogService', () => ({ + useDialogService: () => ({ prompt }) +})) + +beforeEach(() => { + canvas.selectedItems = new Set() + canvas.copyToClipboard.mockReset() + canvas.pasteFromClipboard.mockReset() + canvas.deleteSelected.mockReset() + canvas.setDirty.mockReset() + toastAdd.mockReset() + captureCanvasState.mockReset() + updateSelectedItems.mockReset() + prompt.mockReset() + titleEditor.titleEditorTarget = null + store.selectedItems = [] +}) + +describe('useSelectionOperations', () => { + it('warns and does nothing when copying an empty selection', () => { + useSelectionOperations().copySelection() + expect(canvas.copyToClipboard).not.toHaveBeenCalled() + expect(toastAdd).toHaveBeenCalledWith({ + severity: 'warn', + summary: 'g.nothingToCopy', + detail: 'g.selectItemsToCopy', + life: 3000 + }) + }) + + it('copies a non-empty selection and reports success', () => { + canvas.selectedItems = new Set(['a']) + useSelectionOperations().copySelection() + expect(canvas.copyToClipboard).toHaveBeenCalled() + expect(toastAdd).toHaveBeenCalledWith({ + severity: 'success', + summary: 'g.copied', + detail: 'g.itemsCopiedToClipboard', + life: 2000 + }) + }) + + it('pastes from clipboard and captures canvas state', () => { + useSelectionOperations().pasteSelection() + expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({ + connectInputs: false + }) + expect(captureCanvasState).toHaveBeenCalled() + }) + + it('duplicates by copy, clear, paste', () => { + canvas.selectedItems = new Set(['a']) + useSelectionOperations().duplicateSelection() + expect(canvas.copyToClipboard).toHaveBeenCalled() + expect(canvas.selectedItems.size).toBe(0) + expect(updateSelectedItems).toHaveBeenCalled() + expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({ + connectInputs: false + }) + expect(captureCanvasState).toHaveBeenCalled() + }) + + it('warns when duplicating nothing', () => { + useSelectionOperations().duplicateSelection() + expect(canvas.copyToClipboard).not.toHaveBeenCalled() + expect(toastAdd).toHaveBeenCalledWith({ + severity: 'warn', + summary: 'g.nothingToDuplicate', + detail: 'g.selectItemsToDuplicate', + life: 3000 + }) + }) + + it('deletes a non-empty selection and marks the canvas dirty', () => { + canvas.selectedItems = new Set(['a']) + useSelectionOperations().deleteSelection() + expect(canvas.deleteSelected).toHaveBeenCalled() + expect(canvas.setDirty).toHaveBeenCalledWith(true, true) + expect(captureCanvasState).toHaveBeenCalled() + }) + + it('warns when deleting nothing', () => { + useSelectionOperations().deleteSelection() + expect(canvas.deleteSelected).not.toHaveBeenCalled() + expect(toastAdd).toHaveBeenCalledWith({ + severity: 'warn', + summary: 'g.nothingToDelete', + detail: 'g.selectItemsToDelete', + life: 3000 + }) + }) + + it('routes a single node rename to the title editor', async () => { + const node = new LGraphNode('Test') + store.selectedItems = [node] + + await useSelectionOperations().renameSelection() + + expect(titleEditor.titleEditorTarget).toBe(node) + expect(prompt).not.toHaveBeenCalled() + }) + + it('renames a single non-node item via the prompt dialog', async () => { + const group = { title: 'Old' } + store.selectedItems = [group] + prompt.mockResolvedValue('New') + + await useSelectionOperations().renameSelection() + + expect(group.title).toBe('New') + expect(canvas.setDirty).toHaveBeenCalledWith(true, true) + expect(captureCanvasState).toHaveBeenCalled() + }) + + it('leaves a single titled item unchanged when the prompt returns the same title', async () => { + const group = { title: 'Old' } + store.selectedItems = [group] + prompt.mockResolvedValue('Old') + + await useSelectionOperations().renameSelection() + + expect(group.title).toBe('Old') + expect(canvas.setDirty).not.toHaveBeenCalled() + expect(captureCanvasState).not.toHaveBeenCalled() + }) + + it('does not assign a title to a selected item without a title property', async () => { + const item = {} + store.selectedItems = [item] + prompt.mockResolvedValue('New') + + await useSelectionOperations().renameSelection() + + expect(item).toEqual({}) + expect(canvas.setDirty).not.toHaveBeenCalled() + expect(captureCanvasState).not.toHaveBeenCalled() + }) + + it('batch-renames multiple items with an indexed base name', async () => { + const a = { title: 'a' } + const b = { title: 'b' } + store.selectedItems = [a, b] + prompt.mockResolvedValue('Item') + + await useSelectionOperations().renameSelection() + + expect(a.title).toBe('Item 1') + expect(b.title).toBe('Item 2') + expect(canvas.setDirty).toHaveBeenCalledWith(true, true) + expect(captureCanvasState).toHaveBeenCalled() + }) + + it('skips untitled items during batch rename', async () => { + const a = { title: 'a' } + const b = {} + store.selectedItems = [a, b] + prompt.mockResolvedValue('Item') + + await useSelectionOperations().renameSelection() + + expect(a.title).toBe('Item 1') + expect(b).toEqual({}) + expect(canvas.setDirty).toHaveBeenCalledWith(true, true) + expect(captureCanvasState).toHaveBeenCalled() + }) + + it('leaves a multiple selection unchanged when batch rename is cancelled', async () => { + const a = { title: 'a' } + const b = { title: 'b' } + store.selectedItems = [a, b] + prompt.mockResolvedValue('') + + await useSelectionOperations().renameSelection() + + expect(a.title).toBe('a') + expect(b.title).toBe('b') + expect(canvas.setDirty).not.toHaveBeenCalled() + expect(captureCanvasState).not.toHaveBeenCalled() + }) + + it('warns when renaming an empty selection', async () => { + await useSelectionOperations().renameSelection() + expect(toastAdd).toHaveBeenCalledWith({ + severity: 'warn', + summary: 'g.nothingToRename', + detail: 'g.selectItemsToRename', + life: 3000 + }) + }) +}) diff --git a/src/composables/graph/useSelectionState.test.ts b/src/composables/graph/useSelectionState.test.ts index ea53a25c04c..da131b1236b 100644 --- a/src/composables/graph/useSelectionState.test.ts +++ b/src/composables/graph/useSelectionState.test.ts @@ -8,7 +8,12 @@ import { useSettingStore } from '@/platform/settings/settingStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' -import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil' +import { + isImageNode, + isLGraphGroup, + isLGraphNode, + isLoad3dNode +} from '@/utils/litegraphUtil' import { filterOutputNodes } from '@/utils/nodeFilterUtil' import { createMockLGraphNode, @@ -17,7 +22,9 @@ import { vi.mock('@/utils/litegraphUtil', () => ({ isLGraphNode: vi.fn(), - isImageNode: vi.fn() + isImageNode: vi.fn(), + isLGraphGroup: vi.fn(), + isLoad3dNode: vi.fn() })) vi.mock('@/utils/nodeFilterUtil', () => ({ @@ -96,6 +103,16 @@ describe('useSelectionState', () => { const typedNode = node as { type?: string } return typedNode?.type === 'ImageNode' }) + vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => { + const typedItem = item as { isGroup?: boolean } + return typedItem?.isGroup === true + }) + vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => { + const typedNode = node as { type?: string } + return ( + typedNode?.type === 'Load3D' || typedNode?.type === 'Load3DAnimation' + ) + }) vi.mocked(filterOutputNodes).mockImplementation((nodes) => nodes.filter((n) => n.type === 'OutputNode') ) @@ -135,6 +152,21 @@ describe('useSelectionState', () => { const { hasMultipleSelection } = useSelectionState() expect(hasMultipleSelection.value).toBe(false) }) + + test('hasGroupedNodesSelection should detect a group containing nodes', () => { + const canvasStore = useCanvasStore() + const graphNode = createMockLGraphNode({ id: 2 }) + const group = createMockPositionable({ id: 2000 }) + Object.assign(group, { + isGroup: true, + isNode: false, + children: new Set([graphNode]) + }) + canvasStore.$state.selectedItems = [group] + + const { hasGroupedNodesSelection } = useSelectionState() + expect(hasGroupedNodesSelection.value).toBe(true) + }) }) describe('Node Type Filtering', () => { @@ -215,6 +247,13 @@ describe('useSelectionState', () => { const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true) expect(newIsPinned).toBe(false) }) + + test('should compute default flags for an empty node selection', () => { + expect(useSelectionState().computeSelectionFlags()).toEqual({ + collapsed: false, + pinned: false + }) + }) }) describe('Node Info', () => { diff --git a/src/composables/node/useNodeBadge.test.ts b/src/composables/node/useNodeBadge.test.ts new file mode 100644 index 00000000000..79844ab1c10 --- /dev/null +++ b/src/composables/node/useNodeBadge.test.ts @@ -0,0 +1,315 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, defineComponent, h, nextTick } from 'vue' +import type { App as VueApp } from 'vue' + +import { useNodeBadge } from '@/composables/node/useNodeBadge' +import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { LGraphBadge } from '@/lib/litegraph/src/litegraph' +import type { ComfyExtension } from '@/types/comfy' +import { toNodeId } from '@/types/nodeId' +import { NodeBadgeMode } from '@/types/nodeSource' + +const { + settings, + appState, + extensionState, + nodeDefState, + pricingState, + setDirtyMock, + addEventListenerMock, + registerExtensionMock, + getCreditsBadgeMock, + updateSubgraphCreditsMock, + getNodePricingConfigMock, + getNodeDisplayPriceMock, + getRelevantWidgetNamesMock, + triggerPriceRecalculationMock, + useComputedWithWidgetWatchMock +} = vi.hoisted(() => ({ + settings: {} as Record, + appState: { + graph: { + nodes: [] as unknown[] + } + }, + extensionState: { + installed: false, + registered: undefined as ComfyExtension | undefined + }, + nodeDefState: { + value: null as Record | null + }, + pricingState: { + revision: { value: 0 }, + config: undefined as + | { + depends_on?: { + widgets?: string[] + inputs?: string[] + input_groups?: string[] + } + } + | undefined, + label: '1 credit' + }, + setDirtyMock: vi.fn(), + addEventListenerMock: vi.fn(), + registerExtensionMock: vi.fn((extension: ComfyExtension) => { + extensionState.registered = extension + }), + getCreditsBadgeMock: vi.fn((text: string) => ({ text })), + updateSubgraphCreditsMock: vi.fn(), + getNodePricingConfigMock: vi.fn(() => pricingState.config), + getNodeDisplayPriceMock: vi.fn(() => pricingState.label), + getRelevantWidgetNamesMock: vi.fn(() => ['seed']), + triggerPriceRecalculationMock: vi.fn(), + useComputedWithWidgetWatchMock: vi.fn(() => vi.fn()) +})) + +vi.mock('@/scripts/app', () => ({ + app: { + canvas: { + setDirty: setDirtyMock, + canvas: { + addEventListener: addEventListenerMock + }, + graph: appState.graph + } + } +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: (key: string) => settings[key] + }) +})) + +vi.mock('@/stores/extensionStore', () => ({ + useExtensionStore: () => ({ + isExtensionInstalled: () => extensionState.installed, + registerExtension: registerExtensionMock + }) +})) + +vi.mock('@/stores/nodeDefStore', () => ({ + useNodeDefStore: () => ({ + fromLGraphNode: () => nodeDefState.value + }) +})) + +vi.mock('@/stores/workspace/colorPaletteStore', () => ({ + useColorPaletteStore: () => ({ + completedActivePalette: { + colors: { + litegraph_base: { + BADGE_FG_COLOR: '#fff', + BADGE_BG_COLOR: '#000' + } + } + } + }) +})) + +vi.mock('@/composables/node/useNodePricing', () => ({ + useNodePricing: () => ({ + pricingRevision: pricingState.revision, + getNodePricingConfig: getNodePricingConfigMock, + getNodeDisplayPrice: getNodeDisplayPriceMock, + getRelevantWidgetNames: getRelevantWidgetNamesMock, + triggerPriceRecalculation: triggerPriceRecalculationMock + }) +})) + +vi.mock('@/composables/node/usePriceBadge', () => ({ + usePriceBadge: () => ({ + getCreditsBadge: getCreditsBadgeMock, + updateSubgraphCredits: updateSubgraphCreditsMock + }) +})) + +vi.mock('@/composables/node/useWatchWidget', () => ({ + useComputedWithWidgetWatch: useComputedWithWidgetWatchMock +})) + +class ApiNode extends LGraphNode { + static override nodeData = { name: 'ApiNode', api_node: true } +} + +function mountBadge(): VueApp { + const app = createApp( + defineComponent({ + setup() { + useNodeBadge() + return () => h('div') + } + }) + ) + app.mount(document.createElement('div')) + return app +} + +function registeredExtension(): ComfyExtension { + if (!extensionState.registered) + throw new Error('Missing registered extension') + return extensionState.registered +} + +function comfyApp(): Parameters>[0] { + return {} as Parameters>[0] +} + +function callNodeCreated(node: LGraphNode) { + registeredExtension().nodeCreated?.(node, comfyApp()) +} + +function inputSlot(name: string) { + return new LGraphNode('slot').addInput(name, '*') +} + +function defaultSettings() { + settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None + settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None + settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None + settings['Comfy.NodeBadge.ShowApiPricing'] = false +} + +describe('useNodeBadge', () => { + let mountedApp: VueApp | undefined + + beforeEach(() => { + defaultSettings() + extensionState.installed = false + extensionState.registered = undefined + appState.graph.nodes = [] + nodeDefState.value = null + pricingState.revision.value = 0 + pricingState.config = undefined + pricingState.label = '1 credit' + setDirtyMock.mockClear() + addEventListenerMock.mockClear() + registerExtensionMock.mockClear() + getCreditsBadgeMock.mockClear() + updateSubgraphCreditsMock.mockClear() + getNodePricingConfigMock.mockClear() + getNodeDisplayPriceMock.mockClear() + getRelevantWidgetNamesMock.mockClear() + triggerPriceRecalculationMock.mockClear() + useComputedWithWidgetWatchMock.mockClear() + }) + + afterEach(() => { + mountedApp?.unmount() + mountedApp = undefined + }) + + it('does not register the badge extension twice', async () => { + extensionState.installed = true + mountedApp = mountBadge() + await nextTick() + + expect(registerExtensionMock).not.toHaveBeenCalled() + }) + + it('adds the configured node identity badge', async () => { + settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll + settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll + settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = + NodeBadgeMode.HideBuiltIn + nodeDefState.value = { + isCoreNode: false, + nodeLifeCycleBadgeText: 'Beta', + nodeSource: { badgeText: 'Pack' } + } + const node = new LGraphNode('Test') + node.id = toNodeId('7') + + mountedApp = mountBadge() + await nextTick() + callNodeCreated(node) + const badge = node.badges[0] as () => LGraphBadge + + expect(node.badgePosition).toBe(BadgePosition.TopRight) + expect(badge().text).toBe('#7 Beta Pack') + }) + + it('hides built-in badge text when the mode excludes core nodes', async () => { + settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn + settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll + settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = + NodeBadgeMode.HideBuiltIn + nodeDefState.value = { + isCoreNode: true, + nodeLifeCycleBadgeText: 'Core', + nodeSource: { badgeText: 'Built-in' } + } + const node = new LGraphNode('Core') + node.id = toNodeId('11') + + mountedApp = mountBadge() + await nextTick() + callNodeCreated(node) + const badge = node.badges[0] as () => LGraphBadge + + expect(badge().text).toBe('#11') + }) + + it('adds dynamic API pricing badges and refreshes relevant input changes', async () => { + settings['Comfy.NodeBadge.ShowApiPricing'] = true + pricingState.config = { + depends_on: { + widgets: ['seed'], + inputs: ['image'], + input_groups: ['lora'] + } + } + const originalOnConnectionsChange = vi.fn() + const node = new ApiNode('API') + node.onConnectionsChange = originalOnConnectionsChange + + mountedApp = mountBadge() + await nextTick() + callNodeCreated(node) + + expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, { + widgetNames: ['seed'], + triggerCanvasRedraw: true + }) + expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit') + + const priceBadge = node.badges[1] as () => { text: string } + expect(priceBadge().text).toBe('1 credit') + pricingState.label = '2 credits' + expect(priceBadge().text).toBe('2 credits') + + node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image')) + node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0')) + node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip')) + node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('')) + + expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4) + expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2) + expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node) + }) + + it('updates subgraph credit badges from registered extension hooks', async () => { + const nodes = [new LGraphNode('one'), new LGraphNode('two')] + appState.graph.nodes = nodes + + mountedApp = mountBadge() + await nextTick() + await registeredExtension().init?.(comfyApp()) + await registeredExtension().afterConfigureGraph?.([], comfyApp()) + + const setGraphHandler = addEventListenerMock.mock.calls.find( + ([event]) => event === 'litegraph:set-graph' + )?.[1] + const convertedHandler = addEventListenerMock.mock.calls.find( + ([event]) => event === 'subgraph-converted' + )?.[1] + setGraphHandler?.() + convertedHandler?.({ detail: { subgraphNode: nodes[0] } }) + + expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0]) + expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1]) + }) +}) diff --git a/src/composables/node/useNodePricing.test.ts b/src/composables/node/useNodePricing.test.ts index dc10370620b..cc9c3480a3b 100644 --- a/src/composables/node/useNodePricing.test.ts +++ b/src/composables/node/useNodePricing.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits' import { @@ -12,6 +14,7 @@ import { import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema' +import { useNodeDefStore } from '@/stores/nodeDefStore' import { toNodeId } from '@/types/nodeId' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' @@ -123,6 +126,47 @@ function createMockNode( }) } +async function flushMacrotask(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function resolveDisplayPrice( + node: LGraphNode, + widgetOverrides?: ReadonlyMap +): Promise { + const { getNodeDisplayPrice, pricingRevision } = useNodePricing() + const startRevision = pricingRevision.value + + // Wait on pricingRevision (bumped when async evaluation settles) instead of a + // fixed sleep. A cache hit schedules nothing, so the bounded poll falls through. + getNodeDisplayPrice(node, widgetOverrides) + for (let attempt = 0; attempt < 50; attempt++) { + if (pricingRevision.value !== startRevision) break + await flushMacrotask() + } + + return getNodeDisplayPrice(node, widgetOverrides) +} + +function createStoredNodeDef( + name: string, + price_badge?: PriceBadge +): ComfyNodeDef { + return { + name, + display_name: name, + description: '', + category: 'test', + input: { required: {}, optional: {} }, + output: [], + output_name: [], + output_is_list: [], + output_node: false, + python_module: 'test', + price_badge + } satisfies ComfyNodeDef +} + // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- @@ -189,6 +233,32 @@ describe('useNodePricing', () => { expect(price).toBe(creditsLabel(0.5)) }) + it('should parse numeric strings and reject blank or invalid numbers', async () => { + const expression = + '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}' + const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }]) + + const parsedNode = createMockNodeWithPriceBadge( + 'TestNumericStringNode', + badge, + [{ name: 'count', value: ' 5 ' }] + ) + const blankNode = createMockNodeWithPriceBadge( + 'TestBlankNumericStringNode', + badge, + [{ name: 'count', value: ' ' }] + ) + const invalidNode = createMockNodeWithPriceBadge( + 'TestInvalidNumericStringNode', + badge, + [{ name: 'count', value: 'five' }] + ) + + expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05)) + expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2)) + expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2)) + }) + it('should handle COMBO widget with numeric value', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( @@ -222,6 +292,19 @@ describe('useNodePricing', () => { expect(price).toBe(creditsLabel(0.1)) }) + it('should preserve boolean combo values', async () => { + const node = createMockNodeWithPriceBadge( + 'TestComboBooleanNode', + priceBadge( + '(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}', + [{ name: 'enabled', type: 'COMBO' }] + ), + [{ name: 'enabled', value: false }] + ) + + expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04)) + }) + it('should handle BOOLEAN widget', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( @@ -238,6 +321,51 @@ describe('useNodePricing', () => { expect(price).toBe(creditsLabel(0.1)) }) + it('should parse BOOLEAN widget string values', async () => { + const badge = priceBadge( + '{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}', + [{ name: 'premium', type: 'BOOLEAN' }] + ) + const enabledNode = createMockNodeWithPriceBadge( + 'TestBooleanStringTrueNode', + badge, + [{ name: 'premium', value: ' TRUE ' }] + ) + const disabledNode = createMockNodeWithPriceBadge( + 'TestBooleanStringFalseNode', + badge, + [{ name: 'premium', value: 'false' }] + ) + + expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1)) + expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05)) + }) + + it('should reject invalid BOOLEAN strings', async () => { + const node = createMockNodeWithPriceBadge( + 'TestInvalidBooleanStringNode', + priceBadge( + '{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}', + [{ name: 'premium', type: 'BOOLEAN' }] + ), + [{ name: 'premium', value: 'sometimes' }] + ) + + expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05)) + }) + + it('should reject object values for numeric widgets', async () => { + const node = createMockNodeWithPriceBadge( + 'TestObjectNumericNode', + priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [ + { name: 'count', type: 'INT' } + ]), + [{ name: 'count', value: { count: 5 } }] + ) + + expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05)) + }) + it('should handle STRING widget (lowercased)', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( @@ -468,6 +596,42 @@ describe('useNodePricing', () => { }) }) + describe('dependency context', () => { + it('should prefer widget overrides over node widget values', async () => { + const node = createMockNodeWithPriceBadge( + 'TestWidgetOverrideNode', + priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [ + { name: 'count', type: 'INT' } + ]), + [{ name: 'count', value: 2 }] + ) + + const price = await resolveDisplayPrice(node, new Map([['count', '7']])) + + expect(price).toBe(creditsLabel(0.07)) + }) + + it('should treat missing input group arrays as zero connected inputs', async () => { + const node = Object.assign(createMockLGraphNode(), { + widgets: [], + constructor: { + nodeData: { + name: 'TestMissingInputGroupArrayNode', + api_node: true, + price_badge: priceBadge( + '{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}', + [], + [], + ['images'] + ) + } + } + }) + + expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05)) + }) + }) + describe('edge cases', () => { it('should return empty string for non-API nodes', () => { const { getNodeDisplayPrice } = useNodePricing() @@ -595,6 +759,86 @@ describe('useNodePricing', () => { }) }) + describe('node type pricing dependencies', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('returns empty dependency metadata for node types without pricing', () => { + const store = useNodeDefStore() + store.addNodeDef(createStoredNodeDef('UnpricedNode')) + const { + getInputGroupPrefixes, + getInputNames, + getRelevantWidgetNames, + hasDynamicPricing + } = useNodePricing() + + expect(getRelevantWidgetNames('UnpricedNode')).toEqual([]) + expect(hasDynamicPricing('UnpricedNode')).toBe(false) + expect(getInputGroupPrefixes('UnpricedNode')).toEqual([]) + expect(getInputNames('UnpricedNode')).toEqual([]) + }) + + it('dedupes dynamic pricing dependencies while preserving order', () => { + const store = useNodeDefStore() + store.addNodeDef( + createStoredNodeDef( + 'DynamicPricingNode', + priceBadge( + '{"type":"usd","usd":0.05}', + [ + { name: 'seed', type: 'INT' }, + { name: 'quality', type: 'COMBO' } + ], + ['image', 'seed'], + ['clips', 'image'] + ) + ) + ) + const { + getInputGroupPrefixes, + getInputNames, + getRelevantWidgetNames, + hasDynamicPricing + } = useNodePricing() + + expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([ + 'seed', + 'quality', + 'image', + 'clips' + ]) + expect(hasDynamicPricing('DynamicPricingNode')).toBe(true) + expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([ + 'clips', + 'image' + ]) + expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed']) + }) + + it('handles fixed pricing metadata without dependencies', () => { + const store = useNodeDefStore() + store.addNodeDef( + createStoredNodeDef( + 'FixedPricingNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + ) + const { + getInputGroupPrefixes, + getInputNames, + getRelevantWidgetNames, + hasDynamicPricing + } = useNodePricing() + + expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([]) + expect(hasDynamicPricing('FixedPricingNode')).toBe(false) + expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([]) + expect(getInputNames('FixedPricingNode')).toEqual([]) + }) + }) + describe('reactive revision', () => { it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => { const { getNodeDisplayPrice, pricingRevision } = useNodePricing() @@ -743,6 +987,24 @@ describe('useNodePricing', () => { expect(price).toBe('') }) + it('should reuse the cached empty label after runtime failures', async () => { + const { getNodeDisplayPrice, pricingRevision } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestCachedRuntimeErrorNode', + priceBadge('$lookup(undefined, "key")') + ) + + expect(await resolveDisplayPrice(node)).toBe('') + const revisionAfterFailure = pricingRevision.value + + // A second read of the same signature must reuse the cached empty label + // without scheduling another evaluation (no revision bump). + expect(getNodeDisplayPrice(node)).toBe('') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(getNodeDisplayPrice(node)).toBe('') + expect(pricingRevision.value).toBe(revisionAfterFailure) + }) + it('should return empty string for invalid PricingResult type', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( @@ -968,8 +1230,21 @@ describe('formatPricingResult', () => { expect(result).toBe('~10.6') }) + it('should parse string usd values with default approximate formatting', () => { + const result = formatPricingResult( + { type: 'usd', usd: '0.05' }, + { valueOnly: true, defaults: { approximate: true } } + ) + expect(result).toBe('~10.6') + }) + it('should return empty for null usd', () => { - const result = formatPricingResult({ type: 'usd', usd: null as never }) + const result = formatPricingResult({ type: 'usd', usd: null }) + expect(result).toBe('') + }) + + it('should return empty for blank string usd', () => { + const result = formatPricingResult({ type: 'usd', usd: ' ' }) expect(result).toBe('') }) }) @@ -999,6 +1274,14 @@ describe('formatPricingResult', () => { ) expect(result).toBe('10.6') }) + + it('should parse string range values with default approximate formatting', () => { + const result = formatPricingResult( + { type: 'range_usd', min_usd: '0.05', max_usd: '0.1' }, + { valueOnly: true, defaults: { approximate: true } } + ) + expect(result).toBe('~10.6-21.1') + }) }) describe('type: list_usd', () => { @@ -1017,6 +1300,22 @@ describe('formatPricingResult', () => { ) expect(result).toBe('10.6/21.1') }) + + it('should return valueOnly format with approximate prefix', () => { + const result = formatPricingResult( + { type: 'list_usd', usd: [0.05, 0.1] }, + { valueOnly: true, defaults: { approximate: true } } + ) + expect(result).toBe('~10.6/21.1') + }) + + it('should return empty when list value is not an array', () => { + const result = formatPricingResult({ + type: 'list_usd', + usd: 'not-a-list' + }) + expect(result).toBe('') + }) }) describe('type: text', () => { @@ -1024,6 +1323,11 @@ describe('formatPricingResult', () => { const result = formatPricingResult({ type: 'text', text: 'Free' }) expect(result).toBe('Free') }) + + it('should return empty when text is missing', () => { + const result = formatPricingResult({ type: 'text' }) + expect(result).toBe('') + }) }) describe('legacy format', () => { @@ -1190,6 +1494,29 @@ describe('evaluateNodeDefPricing', () => { expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits }) + it('should use default value from optional input spec', async () => { + const nodeDef = createMockNodeDef({ + name: 'OptionalDefaultValueNode', + price_badge: { + engine: 'jsonata', + expr: '{"type":"usd","usd": widgets.count * 0.01}', + depends_on: { + widgets: [{ name: 'count', type: 'INT' }], + inputs: [], + input_groups: [] + } + }, + input: { + required: {}, + optional: { + count: ['INT', { default: 4 }] + } + } + }) + const result = await evaluateNodeDefPricing(nodeDef) + expect(result).toBe('8.4') + }) + it('should use first option for COMBO without default', async () => { const nodeDef = createMockNodeDef({ name: 'ComboNode', @@ -1265,6 +1592,30 @@ describe('evaluateNodeDefPricing', () => { expect(result).toBe('10.6') }) + it('should handle combo option arrays with primitive values', async () => { + const nodeDef = createMockNodeDef({ + name: 'PrimitiveOptionsNode', + price_badge: { + engine: 'jsonata', + expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}', + depends_on: { + widgets: [{ name: 'mode', type: 'COMBO' }], + inputs: [], + input_groups: [] + } + }, + input: { + required: { + mode: ['COMBO', { options: ['fast', 'slow'] }] + } + } + }) + + const result = await evaluateNodeDefPricing(nodeDef) + + expect(result).toBe('10.6') + }) + it('should assume inputs disconnected in preview', async () => { const nodeDef = createMockNodeDef({ name: 'InputConnectedNode', diff --git a/src/composables/useTreeExpansion.test.ts b/src/composables/useTreeExpansion.test.ts new file mode 100644 index 00000000000..112ef75555a --- /dev/null +++ b/src/composables/useTreeExpansion.test.ts @@ -0,0 +1,102 @@ +import { ref } from 'vue' +import { describe, expect, it } from 'vitest' + +import { useTreeExpansion } from '@/composables/useTreeExpansion' +import type { TreeNode } from '@/types/treeExplorerTypes' + +function node(over: Partial): TreeNode { + return over as TreeNode +} + +// root ─┬─ a ── a1 (leaf) +// └─ b (leaf) +function sampleTree() { + const a1 = node({ key: 'a1', leaf: true }) + const a = node({ key: 'a', leaf: false, children: [a1] }) + const b = node({ key: 'b', leaf: true }) + const root = node({ key: 'root', leaf: false, children: [a, b] }) + return { root, a, a1, b } +} + +describe('useTreeExpansion', () => { + it('toggleNode adds then removes a node key', () => { + const expandedKeys = ref>({}) + const { toggleNode } = useTreeExpansion(expandedKeys) + const n = node({ key: 'x' }) + + toggleNode(n) + expect(expandedKeys.value).toEqual({ x: true }) + + toggleNode(n) + expect(expandedKeys.value).toEqual({}) + }) + + it('toggleNode ignores nodes without a string key', () => { + const expandedKeys = ref>({}) + const { toggleNode } = useTreeExpansion(expandedKeys) + + toggleNode(node({ key: undefined })) + toggleNode(node({ key: 42 as unknown as string })) + + expect(expandedKeys.value).toEqual({}) + }) + + it('expandNode expands the node and all non-leaf descendants only', () => { + const expandedKeys = ref>({}) + const { expandNode } = useTreeExpansion(expandedKeys) + const { root } = sampleTree() + + expandNode(root) + + // root and a are folders; a1 and b are leaves and must be skipped + expect(expandedKeys.value).toEqual({ root: true, a: true }) + }) + + it('expandNode does nothing for a leaf node', () => { + const expandedKeys = ref>({}) + const { expandNode } = useTreeExpansion(expandedKeys) + + expandNode(node({ key: 'leaf', leaf: true })) + + expect(expandedKeys.value).toEqual({}) + }) + + it('collapseNode removes the node and its non-leaf descendants', () => { + const expandedKeys = ref>({ + root: true, + a: true, + stray: true + }) + const { collapseNode } = useTreeExpansion(expandedKeys) + const { root } = sampleTree() + + collapseNode(root) + + expect(expandedKeys.value).toEqual({ stray: true }) + }) + + it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => { + const expandedKeys = ref>({}) + const { toggleNodeRecursive } = useTreeExpansion(expandedKeys) + const { root } = sampleTree() + + toggleNodeRecursive(root) + expect(expandedKeys.value).toEqual({ root: true, a: true }) + + toggleNodeRecursive(root) + expect(expandedKeys.value).toEqual({}) + }) + + it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => { + const expandedKeys = ref>({}) + const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys) + const { root } = sampleTree() + + toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root) + expect(expandedKeys.value).toEqual({ root: true, a: true }) + + // Plain toggle removes only the node's own key, leaving descendants + toggleNodeOnEvent(new MouseEvent('click'), root) + expect(expandedKeys.value).toEqual({ a: true }) + }) +}) diff --git a/src/extensions/core/groupNode.test.ts b/src/extensions/core/groupNode.test.ts index 55ea5c05ace..aa9e91c0a9d 100644 --- a/src/extensions/core/groupNode.test.ts +++ b/src/extensions/core/groupNode.test.ts @@ -1,16 +1,49 @@ -import { describe, expect, it, vi } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink' +import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import type { ComfyApp } from '@/scripts/app' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import type { ComfyExtension } from '@/types/comfy' import type { GroupNodeWorkflowData } from './groupNode' -vi.mock('@/scripts/app', () => ({ - app: { - registerExtension: vi.fn() +const appMock = vi.hoisted(() => ({ + canvas: { + emitAfterChange: vi.fn(), + emitBeforeChange: vi.fn(), + selected_nodes: {} + }, + registerExtension: vi.fn(), + registerNodeDef: vi.fn(), + rootGraph: { + convertToSubgraph: vi.fn(), + extra: {}, + getNodeById: vi.fn(), + links: {}, + nodes: [], + remove: vi.fn() } })) +const widgetStoreMock = vi.hoisted(() => ({ + inputIsWidget: vi.fn((spec: unknown[]) => + ['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0])) + ) +})) + +vi.mock('@/scripts/app', () => ({ + app: appMock +})) + +vi.mock('@/stores/widgetStore', () => ({ + useWidgetStore: () => widgetStoreMock +})) + import { GroupNodeConfig, replaceLegacySeparators } from './groupNode' function makeNode(type: string): ComfyNode { @@ -26,6 +59,42 @@ function makeNode(type: string): ComfyNode { } } +function makeNodeDef(overrides: Partial = {}): ComfyNodeDef { + return { + name: 'TestNode', + display_name: 'Test Node', + description: '', + category: 'test', + input: { required: {}, optional: {} }, + output: [], + output_name: [], + output_is_list: [], + output_node: false, + python_module: 'test', + ...overrides + } as ComfyNodeDef +} + +function extension(): ComfyExtension { + const groupExtension = appMock.registerExtension.mock.calls.find( + ([registered]) => registered.name === 'Comfy.GroupNode' + )?.[0] + if (!groupExtension) throw new Error('GroupNode extension was not registered') + return groupExtension as ComfyExtension +} + +function addCustomNodeDefs(defs: Record) { + extension().addCustomNodeDefs?.(defs, appMock as unknown as ComfyApp) +} + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + appMock.registerNodeDef.mockReset() + widgetStoreMock.inputIsWidget.mockClear() + LiteGraph.registered_node_types = {} + addCustomNodeDefs({}) +}) + describe('replaceLegacySeparators', () => { it('rewrites the legacy "workflow/" prefix to "workflow>"', () => { const nodes = [makeNode('workflow/My Group')] @@ -104,4 +173,398 @@ describe('GroupNodeConfig.getLinks', () => { const config = configFrom([], [[0, 1, 'IMAGE']]) expect(config.externalFrom[0][1]).toBe('IMAGE') }) + + it('ignores external links without a type and accumulates multiple slots', () => { + const config = configFrom( + [], + [ + [0, 1, null as unknown as string], + [0, 2, 'LATENT'], + [0, 3, 'IMAGE'] + ] + ) + + expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' }) + }) +}) + +describe('GroupNodeConfig.getNodeDef', () => { + const imageNodeDef = makeNodeDef({ + name: 'ImageNode', + input: { + required: { + image: ['IMAGE', {}], + mode: [['fast', 'slow'], {}] + }, + optional: { + strength: ['FLOAT', { default: 1 }] + } + }, + output: ['IMAGE'], + output_name: ['image'], + output_is_list: [false] + }) + + beforeEach(() => { + addCustomNodeDefs({ ImageNode: imageNodeDef }) + }) + + it('returns registered definitions for normal node types', () => { + const config = new GroupNodeConfig('group', { + nodes: [{ index: 0, type: 'ImageNode' }], + links: [], + external: [] + }) + + expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe( + imageNodeDef + ) + }) + + it('returns undefined for nodes without an index or a known type', () => { + const config = new GroupNodeConfig('group', { + nodes: [{ type: 'UnknownNode' }], + links: [], + external: [] + }) + + expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined() + }) + + it('skips unlinked primitive nodes', () => { + const config = new GroupNodeConfig('group', { + nodes: [{ index: 0, type: 'PrimitiveNode' }], + links: [], + external: [] + }) + + expect( + config.getNodeDef({ index: 0, type: 'PrimitiveNode' }) + ).toBeUndefined() + }) + + it('derives primitive node type from the outgoing link type', () => { + const config = new GroupNodeConfig('group', { + nodes: [ + { index: 0, type: 'PrimitiveNode' }, + { index: 1, type: 'ImageNode' } + ], + links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray], + external: [] + }) + + expect( + config.getNodeDef({ index: 0, type: 'PrimitiveNode' }) + ).toMatchObject({ + input: { required: { value: ['IMAGE', {}] } }, + output: ['IMAGE'] + }) + }) + + it('falls back to null when primitive combo target spec is not primitive', () => { + const config = new GroupNodeConfig('group', { + nodes: [ + { + index: 0, + type: 'PrimitiveNode', + outputs: [{ name: 'mode', widget: { name: 'mode' } }] + }, + { index: 1, type: 'ImageNode' } + ], + links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray], + external: [] + }) + + expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({ + input: { required: { value: [null, {}] } }, + output: [null] + }) + }) + + it('returns null for reroutes used only inside the group', () => { + const config = new GroupNodeConfig('group', { + nodes: [ + { index: 0, type: 'ImageNode' }, + { index: 1, type: 'Reroute' }, + { index: 2, type: 'ImageNode' } + ], + links: [ + [0, 0, 1, 0, 1, 'IMAGE'], + [1, 0, 2, 0, 2, 'IMAGE'] + ] as SerialisedLLinkArray[], + external: [] + }) + + expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull() + }) + + it('derives reroute type from outgoing target inputs', () => { + const config = new GroupNodeConfig('group', { + nodes: [ + { index: 0, type: 'Reroute' }, + { + index: 1, + type: 'ImageNode', + inputs: [{ name: 'image', type: 'IMAGE' }] + } + ], + links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray], + external: [[0, 0, 'IMAGE']] + }) + + expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({ + input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } }, + output: ['IMAGE'] + }) + }) + + it('derives reroute type from incoming output metadata', () => { + const config = new GroupNodeConfig('group', { + nodes: [ + { index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] }, + { index: 1, type: 'Reroute' } + ], + links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray], + external: [[1, 0, 'LATENT']] + }) + + expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({ + input: { required: { LATENT: ['LATENT', { forceInput: true }] } }, + output: ['LATENT'] + }) + }) + + it('derives pipe reroute type from external metadata when links omit it', () => { + const config = new GroupNodeConfig('group', { + nodes: [{ index: 0, type: 'Reroute' }], + links: [], + external: [[0, 0, 'MASK']] + }) + + expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({ + input: { required: { MASK: ['MASK', { forceInput: true }] } }, + output: ['MASK'] + }) + }) +}) + +describe('GroupNodeConfig input and output mapping', () => { + function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) { + const config = new GroupNodeConfig('group', { + nodes: [node], + links: [], + external: [], + config: { + 0: { + input: { + hidden: { visible: false }, + renamed: { name: 'Custom Name' } + }, + output: { + 1: { name: 'Custom Output' }, + 2: { visible: false } + } + } + } + }) + config.nodeDef = makeNodeDef({ + input: { required: {} }, + output: [], + output_name: [], + output_is_list: [] + }) + return config + } + + it('renames duplicate inputs and adds seed control metadata', () => { + const config = configWithNode({ + index: 0, + type: 'Sampler', + title: 'Sampler A', + inputs: [{ name: 'seed', label: 'Seed Label' }] + }) + const seenInputs = { seed: 1, 'Sampler A seed': 1 } + const result = config.getInputConfig( + { index: 0, type: 'Sampler', title: 'Sampler A' }, + 'seed', + seenInputs, + ['INT', {}] + ) + + expect(result.name).toBe('Sampler A 1 seed') + expect(result.config).toEqual([ + 'INT', + { control_after_generate: 'Sampler A control_after_generate' } + ]) + }) + + it('maps image upload widget aliases through converted widget names', () => { + const config = configWithNode({ index: 0, type: 'LoadImage' }) + config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' } + + expect( + config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [ + 'IMAGEUPLOAD', + { widget: 'customImage' } + ]) + ).toMatchObject({ + name: 'Custom Name', + config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }] + }) + }) + + it('splits widget inputs, socket inputs, and converted widget slots', () => { + const config = configWithNode({ + index: 0, + type: 'MixedNode', + inputs: [{ name: 'mode', widget: { name: 'mode' } }] + }) + + const result = config.processWidgetInputs( + { + mode: ['COMBO', {}], + image: ['IMAGE', {}] + }, + { + index: 0, + type: 'MixedNode', + inputs: [{ name: 'mode', widget: { name: 'mode' } }] + }, + ['mode', 'image'], + {} + ) + + expect(result.slots).toEqual(['image']) + expect(result.converted.get(0)).toBe('mode') + expect(config.oldToNewWidgetMap[0].mode).toBeNull() + }) + + it('adds visible unlinked input slots and skips hidden configured inputs', () => { + const config = configWithNode({ + index: 0, + type: 'InputNode' + }) + const inputMap: Record = {} + config.processInputSlots( + { + image: ['IMAGE', {}], + hidden: ['LATENT', {}] + }, + { index: 0, type: 'InputNode' }, + ['image', 'hidden'], + {}, + inputMap, + {} + ) + + expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] }) + expect(inputMap).toEqual({ 0: 0 }) + }) + + it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => { + const config = configWithNode({ + index: 0, + type: 'OutputNode', + title: 'Output A', + outputs: [{ name: 'image', label: 'Rendered' }] + }) + config.linksFrom[0] = { + 0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray] + } + config.processNodeOutputs( + { index: 0, type: 'OutputNode', title: 'Output A' }, + { Rendered: 1 }, + { + input: { required: {} }, + output: ['IMAGE', 'LATENT', 'MASK'], + output_name: ['image', 'latent', 'mask'], + output_is_list: [false, true, false] + } + ) + + expect(config.outputVisibility).toEqual([false, true, false]) + expect(config.nodeDef?.output).toEqual(['LATENT']) + expect(config.nodeDef?.output_is_list).toEqual([true]) + expect(config.nodeDef?.output_name).toEqual(['Custom Output']) + }) +}) + +describe('GroupNodeConfig.registerFromWorkflow', () => { + it('adds missing type actions and skips registration for incomplete groups', async () => { + const groupNodes: Record = { + Broken: { + nodes: [{ index: 0, type: 'MissingNode' }], + links: [], + external: [] + } + } + const missingNodeTypes: Parameters< + typeof GroupNodeConfig.registerFromWorkflow + >[1] = [] + + await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes) + + expect(appMock.registerNodeDef).not.toHaveBeenCalled() + expect(missingNodeTypes).toHaveLength(2) + expect(missingNodeTypes[0]).toMatchObject({ + type: 'MissingNode', + hint: " (In group node 'workflow>Broken')" + }) + + const action = missingNodeTypes[1] + if (typeof action === 'string') { + throw new Error('Expected a missing-node action entry, not a string') + } + + const target = document.createElement('button') + const { callback } = action.action as { + callback: (event: MouseEvent) => void + } + const event = new MouseEvent('click') + Object.defineProperty(event, 'target', { value: target }) + callback(event) + expect(groupNodes.Broken).toBeUndefined() + expect(target.textContent).toBe('Removed') + expect(target.style.pointerEvents).toBe('none') + }) + + it('registers complete group node types and stores their generated node defs', async () => { + addCustomNodeDefs({ + ImageNode: makeNodeDef({ + name: 'ImageNode', + input: { required: { image: ['IMAGE', {}] } }, + output: ['IMAGE'], + output_name: ['image'], + output_is_list: [false] + }) + }) + LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {} + + await GroupNodeConfig.registerFromWorkflow( + { + Complete: { + nodes: [{ index: 0, type: 'ImageNode' }], + links: [], + external: [[0, 0, 'IMAGE']] + } + }, + [] + ) + + expect(appMock.registerNodeDef).toHaveBeenCalledWith( + 'workflow>Complete', + expect.objectContaining({ + category: 'group nodes>workflow', + display_name: 'Complete', + name: 'workflow>Complete' + }) + ) + expect(useNodeDefStore().nodeDefsByName['workflow>Complete']).toEqual( + expect.objectContaining({ + category: 'group nodes>workflow', + display_name: 'Complete', + name: 'workflow>Complete' + }) + ) + }) }) diff --git a/src/platform/settings/composables/useSettingUI.test.ts b/src/platform/settings/composables/useSettingUI.test.ts index 4d8e5983a0a..dabc4993d08 100644 --- a/src/platform/settings/composables/useSettingUI.test.ts +++ b/src/platform/settings/composables/useSettingUI.test.ts @@ -1,7 +1,6 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' import { getSettingInfo, @@ -11,31 +10,47 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore' import { useSettingUI } from './useSettingUI' +const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({ + auth: { isLoggedIn: { value: false } }, + billing: { isActiveSubscription: { value: false } }, + dist: { isCloud: false, isDesktop: false }, + featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }, + vueFlags: { shouldRenderVueNodes: { value: false } } +})) + vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (_: string, fallback: string) => fallback }) })) vi.mock('@/composables/auth/useCurrentUser', () => ({ - useCurrentUser: () => ({ isLoggedIn: ref(false) }) + useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn }) })) vi.mock('@/composables/billing/useBillingContext', () => ({ - useBillingContext: () => ({ isActiveSubscription: ref(false) }) + useBillingContext: () => ({ + isActiveSubscription: billing.isActiveSubscription + }) })) vi.mock('@/composables/useFeatureFlags', () => ({ useFeatureFlags: () => ({ - flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false } + flags: featureFlags }) })) vi.mock('@/composables/useVueFeatureFlags', () => ({ - useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) }) + useVueFeatureFlags: () => ({ + shouldRenderVueNodes: vueFlags.shouldRenderVueNodes + }) })) vi.mock('@/platform/distribution/types', () => ({ - isCloud: false, - isDesktop: false + get isCloud() { + return dist.isCloud + }, + get isDesktop() { + return dist.isDesktop + } })) vi.mock('@/platform/settings/settingStore', () => ({ @@ -49,6 +64,7 @@ interface MockSettingParams { type: string defaultValue: unknown category?: string[] + hideInVueNodes?: boolean } describe('useSettingUI', () => { @@ -72,13 +88,23 @@ describe('useSettingUI', () => { defaultValue: 'dark' } } + let settingsById: Record beforeEach(() => { setActivePinia(createTestingPinia()) vi.clearAllMocks() - + auth.isLoggedIn.value = false + billing.isActiveSubscription.value = false + dist.isCloud = false + dist.isDesktop = false + featureFlags.teamWorkspacesEnabled = false + featureFlags.userSecretsEnabled = false + vueFlags.shouldRenderVueNodes.value = false + Object.assign(window, { __CONFIG__: {} }) + + settingsById = mockSettings vi.mocked(useSettingStore).mockReturnValue({ - settingsById: mockSettings + settingsById } as ReturnType) vi.mocked(getSettingInfo).mockImplementation((setting) => { @@ -107,9 +133,9 @@ describe('useSettingUI', () => { undefined, 'Comfy.Locale' ) - const comfyCategory = findCategory(settingCategories.value, 'Comfy') - expect(comfyCategory).toBeDefined() - expect(defaultCategory.value).toBe(comfyCategory) + expect(defaultCategory.value).toBe( + findCategory(settingCategories.value, 'Comfy') + ) }) it('resolves different category from scrollToSettingId', () => { @@ -121,7 +147,6 @@ describe('useSettingUI', () => { settingCategories.value, 'Appearance' ) - expect(appearanceCategory).toBeDefined() expect(defaultCategory.value).toBe(appearanceCategory) }) @@ -137,4 +162,82 @@ describe('useSettingUI', () => { const { defaultCategory } = useSettingUI('about', 'Comfy.Locale') expect(defaultCategory.value.key).toBe('about') }) + + it('falls back when defaultPanel is not in the menu', () => { + const missingPanel = 'missing' as unknown as Parameters< + typeof useSettingUI + >[0] + const { defaultCategory, settingCategories } = useSettingUI(missingPanel) + expect(defaultCategory.value).toBe(settingCategories.value[0]) + }) + + it('moves floating settings into Other and hides Vue-node-only settings', () => { + settingsById = { + Floating: { + id: 'Floating', + name: 'Floating', + type: 'boolean', + defaultValue: false + }, + 'Hidden.Setting': { + id: 'Hidden.Setting', + name: 'Hidden', + type: 'hidden', + defaultValue: false + }, + 'Vue.Hidden': { + id: 'Vue.Hidden', + name: 'Vue Hidden', + type: 'boolean', + defaultValue: false, + hideInVueNodes: true + } + } + vi.mocked(useSettingStore).mockReturnValue({ + settingsById + } as ReturnType) + vueFlags.shouldRenderVueNodes.value = true + + const { settingCategories } = useSettingUI() + + expect(settingCategories.value.map((category) => category.label)).toEqual([ + 'Other' + ]) + expect( + settingCategories.value[0].children?.map((node) => node.key) + ).toEqual(['root/Floating']) + }) + + it('adds gated cloud, desktop, workspace, and secrets panels', () => { + auth.isLoggedIn.value = true + billing.isActiveSubscription.value = true + dist.isCloud = true + dist.isDesktop = true + featureFlags.teamWorkspacesEnabled = true + featureFlags.userSecretsEnabled = true + Object.assign(window, { __CONFIG__: { subscription_required: true } }) + + const { findCategoryByKey, findPanelByKey, navGroups, panels } = + useSettingUI() + + expect(panels.value.map((panel) => panel.node.key)).toEqual([ + 'about', + 'credits', + 'user', + 'workspace', + 'keybinding', + 'extension', + 'server-config', + 'subscription', + 'secrets' + ]) + expect(navGroups.value.map((group) => group.title)).toEqual([ + 'Workspace', + 'General' + ]) + expect(findCategoryByKey('secrets')?.key).toBe('secrets') + expect(findCategoryByKey('missing')).toBeNull() + expect(findPanelByKey('subscription')?.node.key).toBe('subscription') + expect(findPanelByKey('missing')).toBeNull() + }) }) diff --git a/src/platform/workflow/management/stores/workflowLists.test.ts b/src/platform/workflow/management/stores/workflowLists.test.ts new file mode 100644 index 00000000000..7a479e50441 --- /dev/null +++ b/src/platform/workflow/management/stores/workflowLists.test.ts @@ -0,0 +1,93 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import { + useWorkflowBookmarkStore, + useWorkflowStore +} from '@/platform/workflow/management/stores/workflowStore' + +vi.mock('@/scripts/app', () => ({ app: {} })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: () => {}, + getUserData: async () => ({ status: 404 }), + storeUserData: async () => {} + } +})) + +vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({ + useWorkflowThumbnail: () => ({ + moveWorkflowThumbnail: () => {}, + clearThumbnail: () => {} + }) +})) + +vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({ + useWorkflowDraftStoreV2: () => ({ + getDraft: () => null, + saveDraft: () => {}, + deleteDraft: () => {} + }) +})) + +interface WorkflowFlags { + path: string + isPersisted?: boolean + isModified?: boolean +} + +function wf(flags: WorkflowFlags): ComfyWorkflow { + return flags as unknown as ComfyWorkflow +} + +function paths(workflows: ComfyWorkflow[]) { + return workflows.map((w) => w.path) +} + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('workflowStore workflow lists', () => { + it('persistedWorkflows excludes unpersisted and subgraph entries', () => { + const store = useWorkflowStore() + store.attachWorkflow(wf({ path: 'a.json', isPersisted: true })) + store.attachWorkflow(wf({ path: 'b.json', isPersisted: false })) + store.attachWorkflow(wf({ path: 'subgraphs/c.json', isPersisted: true })) + + expect(paths(store.persistedWorkflows)).toEqual(['a.json']) + }) + + it('modifiedWorkflows includes only modified workflows', () => { + const store = useWorkflowStore() + store.attachWorkflow(wf({ path: 'a.json', isModified: true })) + store.attachWorkflow(wf({ path: 'b.json', isModified: false })) + + expect(paths(store.modifiedWorkflows)).toEqual(['a.json']) + }) + + it('bookmarkedWorkflows is empty when nothing is bookmarked', () => { + const store = useWorkflowStore() + store.attachWorkflow(wf({ path: 'a.json' })) + + expect(store.bookmarkedWorkflows).toEqual([]) + }) + + it('bookmarkedWorkflows includes only bookmarked workflows', async () => { + const store = useWorkflowStore() + store.attachWorkflow(wf({ path: 'a.json' })) + store.attachWorkflow(wf({ path: 'b.json' })) + await useWorkflowBookmarkStore().setBookmarked('a.json', true) + + expect(paths(store.bookmarkedWorkflows)).toEqual(['a.json']) + }) + + it('openedWorkflowIndexShift returns null when no workflow is active', () => { + const store = useWorkflowStore() + store.attachWorkflow(wf({ path: 'a.json' }), 0) + + expect(store.openedWorkflowIndexShift(1)).toBeNull() + }) +}) diff --git a/src/platform/workflow/management/stores/workflowNodeLocator.test.ts b/src/platform/workflow/management/stores/workflowNodeLocator.test.ts new file mode 100644 index 00000000000..57014718205 --- /dev/null +++ b/src/platform/workflow/management/stores/workflowNodeLocator.test.ts @@ -0,0 +1,93 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Subgraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { createNodeLocatorId } from '@/types/nodeIdentification' +import { toNodeId } from '@/types/nodeId' + +vi.mock('@/scripts/app', () => ({ app: {} })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: () => {}, + getUserData: async () => ({ status: 404 }), + storeUserData: async () => {} + } +})) + +vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({ + useWorkflowThumbnail: () => ({ + moveWorkflowThumbnail: () => {}, + clearThumbnail: () => {} + }) +})) + +vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({ + useWorkflowDraftStoreV2: () => ({ + getDraft: () => null, + saveDraft: () => {}, + deleteDraft: () => {} + }) +})) + +const SUBGRAPH_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('workflowStore node locator translation', () => { + it('treats a node as a root-graph node when no subgraph is active', () => { + const store = useWorkflowStore() + expect(store.nodeIdToNodeLocatorId(toNodeId(5))).toBe('5') + }) + + it('prefixes the locator with an explicit subgraph uuid', () => { + const store = useWorkflowStore() + const subgraph = { id: SUBGRAPH_UUID } as unknown as Subgraph + + expect(store.nodeIdToNodeLocatorId(toNodeId(5), subgraph)).toBe( + `${SUBGRAPH_UUID}:5` + ) + }) + + it('derives a locator from a node based on whether its graph is a subgraph', () => { + const store = useWorkflowStore() + const rootNode = { id: toNodeId(7), graph: {} } as unknown as LGraphNode + expect(store.nodeToNodeLocatorId(rootNode)).toBe('7') + + const subgraphNode = { + id: toNodeId(7), + graph: { id: SUBGRAPH_UUID, isRootGraph: false } + } as unknown as LGraphNode + expect(store.nodeToNodeLocatorId(subgraphNode)).toBe(`${SUBGRAPH_UUID}:7`) + }) + + it('extracts the local node id from a locator', () => { + const store = useWorkflowStore() + expect( + store.nodeLocatorIdToNodeId( + createNodeLocatorId(SUBGRAPH_UUID, toNodeId(5)) + ) + ).toBe(toNodeId(5)) + expect( + store.nodeLocatorIdToNodeId(createNodeLocatorId(null, toNodeId(9))) + ).toBe(toNodeId(9)) + }) + + it('round-trips a root node id through locator translation', () => { + const store = useWorkflowStore() + const locator = store.nodeIdToNodeLocatorId(toNodeId(42)) + expect(store.nodeLocatorIdToNodeId(locator)).toBe(toNodeId(42)) + }) + + it('maps a root locator to a single-segment execution id', () => { + const store = useWorkflowStore() + expect( + store.nodeLocatorIdToNodeExecutionId( + createNodeLocatorId(null, toNodeId(5)) + ) + ).toBe('5') + }) +}) diff --git a/src/platform/workflow/management/stores/workflowTabs.test.ts b/src/platform/workflow/management/stores/workflowTabs.test.ts new file mode 100644 index 00000000000..1d4c90289d5 --- /dev/null +++ b/src/platform/workflow/management/stores/workflowTabs.test.ts @@ -0,0 +1,100 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' + +vi.mock('@/scripts/app', () => ({ app: {} })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: () => {}, + getUserData: async () => ({ status: 404 }), + storeUserData: async () => {} + } +})) + +vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({ + useWorkflowThumbnail: () => ({ + moveWorkflowThumbnail: () => {}, + clearThumbnail: () => {} + }) +})) + +vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({ + useWorkflowDraftStoreV2: () => ({ + getDraft: () => null, + saveDraft: () => {}, + deleteDraft: () => {} + }) +})) + +function wf(path: string): ComfyWorkflow { + return { path } as unknown as ComfyWorkflow +} + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('workflowStore tab management', () => { + it('attaches workflows into the lookup and finds them by path', () => { + const store = useWorkflowStore() + const a = wf('a.json') + store.attachWorkflow(a) + + // Pinia wraps stored objects in reactive proxies, so compare structurally. + expect(store.getWorkflowByPath('a.json')).toEqual(a) + expect(store.getWorkflowByPath('missing.json')).toBeNull() + expect(store.workflows).toContainEqual(a) + }) + + it('tracks which workflows are open', () => { + const store = useWorkflowStore() + const open = wf('open.json') + const closed = wf('closed.json') + store.attachWorkflow(open, 0) + store.attachWorkflow(closed) + + expect(store.isOpen(open)).toBe(true) + expect(store.isOpen(closed)).toBe(false) + expect(store.openWorkflows).toEqual([open]) + }) + + it('reorders open workflow tabs', () => { + const store = useWorkflowStore() + const a = wf('a.json') + const b = wf('b.json') + const c = wf('c.json') + store.attachWorkflow(a, 0) + store.attachWorkflow(b, 1) + store.attachWorkflow(c, 2) + + store.reorderWorkflows(0, 2) + + expect(store.openWorkflows).toEqual([b, c, a]) + }) + + it('opens background workflows on the requested side, ignoring unknown paths', () => { + const store = useWorkflowStore() + const left = wf('left.json') + const mid = wf('mid.json') + const right = wf('right.json') + store.attachWorkflow(left) + store.attachWorkflow(mid, 0) + store.attachWorkflow(right) + + store.openWorkflowsInBackground({ + left: ['left.json', 'unknown.json'], + right: ['right.json'] + }) + + expect(store.openWorkflows).toEqual([left, mid, right]) + expect(store.activeWorkflow).toBeNull() + }) + + it('reports no active workflow before one is opened', () => { + const store = useWorkflowStore() + expect(store.isActive(wf('a.json'))).toBe(false) + }) +}) diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.test.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.test.ts new file mode 100644 index 00000000000..57743877475 --- /dev/null +++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.test.ts @@ -0,0 +1,247 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' +import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template' +import type { NavGroupData, NavItemData } from '@/types/navTypes' + +const { coreByLocale, coreResult, customResult, dist, locale } = vi.hoisted( + () => ({ + coreByLocale: { value: {} as Record }, + coreResult: { value: [] as unknown[] }, + customResult: { value: {} as Record }, + dist: { isCloud: false }, + locale: { value: 'en' } + }) +) + +const baseTemplate = { + name: 'default', + title: 'Default', + description: 'A basic template', + mediaType: 'image', + mediaSubtype: 'webp' +} + +vi.mock('@/scripts/api', () => ({ + api: { + getWorkflowTemplates: async () => customResult.value, + getCoreWorkflowTemplates: async (locale: string) => + coreByLocale.value[locale] ?? coreResult.value, + fileURL: (p: string) => p + } +})) + +vi.mock('@/i18n', () => ({ + i18n: { global: { locale } }, + st: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return dist.isCloud + } +})) + +function coreCategory( + overrides: Partial = {} +): WorkflowTemplates { + return { + moduleName: 'default', + title: 'Basics', + type: 'image', + templates: [baseTemplate], + ...overrides + } +} + +function navItems(items: (NavItemData | NavGroupData)[]) { + return items.flatMap((item) => ('items' in item ? item.items : [item])) +} + +beforeEach(() => { + setActivePinia(createPinia()) + coreByLocale.value = {} + coreResult.value = [coreCategory()] + customResult.value = {} + dist.isCloud = false + locale.value = 'en' + vi.stubGlobal( + 'fetch', + vi.fn( + async () => new Response('', { headers: { 'content-type': 'text/html' } }) + ) + ) +}) + +describe('workflowTemplatesStore', () => { + it('loads core templates and indexes their names', async () => { + const store = useWorkflowTemplatesStore() + expect(store.isLoaded).toBe(false) + + await store.loadWorkflowTemplates() + + expect(store.isLoaded).toBe(true) + expect(store.knownTemplateNames.has('default')).toBe(true) + expect(store.getTemplateByName('default')?.name).toBe('default') + expect(store.getTemplateByName('missing')).toBeUndefined() + }) + + it('exposes grouped templates with localized titles', async () => { + const store = useWorkflowTemplatesStore() + await store.loadWorkflowTemplates() + + expect(store.groupedTemplates.length).toBeGreaterThan(0) + + const exampleGroup = store.groupedTemplates[0] + expect(exampleGroup.label).toBe('ComfyUI Examples') + const moduleTitles = exampleGroup.modules.map((m) => m.localizedTitle) + expect(moduleTitles).toContain('All Templates') + expect(moduleTitles).toContain('Basics') + + const allNames = store.groupedTemplates.flatMap((g) => + (g.modules ?? []).flatMap((m) => (m.templates ?? []).map((t) => t.name)) + ) + expect(allNames).toContain('default') + }) + + it('filters nav categories from loaded template metadata', async () => { + coreResult.value = [ + coreCategory({ + title: 'Getting Started', + isEssential: true, + templates: [{ ...baseTemplate, name: 'starter', title: 'Starter' }] + }), + coreCategory({ + title: 'Image Tools', + category: 'GENERATION TYPE', + templates: [ + { + ...baseTemplate, + name: 'partner-upscale', + title: 'Partner Upscale', + openSource: false + }, + { + ...baseTemplate, + name: 'local-only', + requiresCustomNodes: ['custom-node'] + } + ] + }) + ] + customResult.value = { CustomPack: ['custom-flow'] } + const store = useWorkflowTemplatesStore() + + await store.loadWorkflowTemplates() + + const allItems = navItems(store.navGroupedTemplates) + const basicsId = allItems.find( + (item) => item.label === 'Getting Started' + )?.id + const categoryId = allItems.find((item) => item.label === 'Image Tools')?.id + + expect(store.filterTemplatesByCategory('all').map((t) => t.name)).toEqual([ + 'starter', + 'partner-upscale', + 'custom-flow' + ]) + expect( + store.filterTemplatesByCategory('popular').map((t) => t.name) + ).toEqual(['starter', 'partner-upscale', 'custom-flow']) + expect( + store.filterTemplatesByCategory(basicsId ?? '').map((t) => t.name) + ).toEqual(['starter']) + expect( + store.filterTemplatesByCategory(categoryId ?? '').map((t) => t.name) + ).toEqual(['partner-upscale']) + expect( + store.filterTemplatesByCategory('partner-nodes').map((t) => t.name) + ).toEqual(['partner-upscale']) + expect( + store.filterTemplatesByCategory('extension-CustomPack').map((t) => t.name) + ).toEqual(['custom-flow']) + expect( + store.filterTemplatesByCategory('unknown').map((t) => t.name) + ).toEqual(['starter', 'partner-upscale', 'custom-flow']) + }) + + it('loads logo indexes and rejects unsafe logo paths', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + valid: 'logos/valid.svg', + missingExtension: 'logos/valid', + parent: '../secret.svg', + rooted: '/logos/rooted.svg' + }), + { headers: { 'content-type': 'application/json' } } + ) + ) + const store = useWorkflowTemplatesStore() + + await store.loadWorkflowTemplates() + + expect(store.getLogoUrl('valid')).toBe('/templates/logos/valid.svg') + expect(store.getLogoUrl('missing')).toBe('') + expect(store.getLogoUrl('missingExtension')).toBe('') + expect(store.getLogoUrl('parent')).toBe('') + expect(store.getLogoUrl('rooted')).toBe('') + }) + + it('returns english metadata when cloud loads a non-english locale', async () => { + dist.isCloud = true + locale.value = 'fr' + coreByLocale.value = { + fr: [ + coreCategory({ + templates: [{ ...baseTemplate, name: 'localized', title: 'Localise' }] + }) + ], + en: [ + coreCategory({ + title: 'English Category', + templates: [ + { + ...baseTemplate, + name: 'localized', + tags: ['tag'], + useCase: 'test', + models: ['model'], + license: 'MIT' + } + ] + }) + ] + } + const store = useWorkflowTemplatesStore() + + await store.loadWorkflowTemplates() + + expect(store.getEnglishMetadata('localized')).toEqual({ + tags: ['tag'], + category: 'English Category', + useCase: 'test', + models: ['model'], + license: 'MIT' + }) + expect(store.getEnglishMetadata('missing')).toBeNull() + }) + + it('does not refetch once loaded', async () => { + const store = useWorkflowTemplatesStore() + await store.loadWorkflowTemplates() + + coreResult.value = [] + await store.loadWorkflowTemplates() + + expect(store.knownTemplateNames.has('default')).toBe(true) + }) + + it('returns null english metadata when no english templates are loaded', async () => { + const store = useWorkflowTemplatesStore() + await store.loadWorkflowTemplates() + + expect(store.getEnglishMetadata('default')).toBeNull() + }) +}) diff --git a/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.test.ts b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.test.ts new file mode 100644 index 00000000000..81beeb46681 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { LGraphBadge } from '@/lib/litegraph/src/litegraph' +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' +import { + trackNodePrice, + usePartitionedBadges +} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges' +import { toNodeId } from '@/types/nodeId' +import { NodeBadgeMode } from '@/types/nodeSource' + +const { settings, nodeDefs, pricing, getNodeRevisionRefMock, getWidgetMock } = + vi.hoisted(() => ({ + settings: {} as Record, + nodeDefs: {} as Record, + pricing: { + dynamic: false, + widgets: [] as string[], + inputs: [] as string[], + groups: [] as string[] + }, + getNodeRevisionRefMock: vi.fn(() => ({ value: 0 })), + getWidgetMock: vi.fn(() => ({ value: 'widget-value' })) + })) + +vi.mock('@/scripts/app', () => ({ + app: { + canvas: { graph: { getNodeById: () => null, rootGraph: { id: 'g1' } } } + } +})) + +vi.mock('@/composables/node/useNodePricing', () => ({ + useNodePricing: () => ({ + getRelevantWidgetNames: () => pricing.widgets, + hasDynamicPricing: () => pricing.dynamic, + getInputGroupPrefixes: () => pricing.groups, + getInputNames: () => pricing.inputs, + getNodeRevisionRef: getNodeRevisionRefMock + }) +})) + +vi.mock('@/composables/node/usePriceBadge', () => ({ + usePriceBadge: () => ({ + isCreditsBadge: (b: { text?: string }) => b.text?.startsWith('$') ?? false + }) +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ get: (key: string) => settings[key] }) +})) + +vi.mock('@/stores/nodeDefStore', () => ({ + useNodeDefStore: () => ({ nodeDefsByName: nodeDefs }) +})) + +vi.mock('@/stores/widgetValueStore', () => ({ + useWidgetValueStore: () => ({ getWidget: getWidgetMock }) +})) + +function nodeData(overrides: Partial = {}): VueNodeData { + return { + executing: false, + id: toNodeId(1), + mode: 0, + selected: false, + title: 'Test node', + type: 'TestNode', + apiNode: false, + badges: [], + inputs: [], + ...overrides + } satisfies VueNodeData +} + +function inputSlot( + name: string, + readLink: () => number | null +): INodeInputSlot { + return { + name, + type: '*', + boundingRect: [0, 0, 0, 0], + get link() { + return readLink() + }, + set link(_value: number | null) {} + } as INodeInputSlot +} + +function badge(text: string): LGraphBadge { + return new LGraphBadge({ text }) +} + +beforeEach(() => { + settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None + settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None + settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None + for (const k of Object.keys(nodeDefs)) delete nodeDefs[k] + nodeDefs['TestNode'] = { isCoreNode: false } + pricing.dynamic = false + pricing.widgets = [] + pricing.inputs = [] + pricing.groups = [] + getNodeRevisionRefMock.mockClear() + getWidgetMock.mockClear() +}) + +describe('usePartitionedBadges', () => { + it('emits no core badges when every badge mode is None', () => { + const result = usePartitionedBadges(nodeData()).value + expect(result.core).toEqual([]) + }) + + it('tracks dynamic-pricing dependencies for an api node without throwing', () => { + pricing.dynamic = true + pricing.widgets = ['seed'] + pricing.inputs = ['model'] + pricing.groups = ['lora'] + const result = usePartitionedBadges( + nodeData({ + apiNode: true, + inputs: [ + inputSlot('model', () => 1), + inputSlot('lora.0', () => 2), + inputSlot('unrelated', () => null) + ] + }) + ).value + + expect(result).toHaveProperty('core') + expect(result).toHaveProperty('extension') + }) + + it('adds an id badge when the id mode is enabled', () => { + settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll + const result = usePartitionedBadges(nodeData({ id: toNodeId(7) })).value + expect(result.core).toContainEqual({ text: '#7' }) + }) + + it('adds a lifecycle badge, trimmed of brackets', () => { + settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll + nodeDefs['TestNode'] = { + isCoreNode: false, + nodeLifeCycleBadgeText: '[BETA]' + } + const result = usePartitionedBadges(nodeData()).value + expect(result.core).toContainEqual({ text: 'BETA' }) + }) + + it('adds a source badge for non-core nodes when source mode is on', () => { + settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll + nodeDefs['TestNode'] = { + isCoreNode: false, + nodeSource: { badgeText: 'my-pack' } + } + const result = usePartitionedBadges(nodeData()).value + expect(result.core).toContainEqual({ text: 'my-pack' }) + }) + + it('partitions extension badges (skipping the first) from credits badges', () => { + const result = usePartitionedBadges( + nodeData({ + badges: [badge('skipped'), badge('ext-badge'), badge('$5 per run')] + }) + ).value + + expect(result.extension.map((badge) => badge.text)).toEqual(['ext-badge']) + expect(result.pricing).toEqual([{ required: '$5', rest: 'per run' }]) + }) + + it('flags hasComfyBadge for a core node with source ShowAll and no pricing', () => { + settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll + nodeDefs['TestNode'] = { isCoreNode: true } + const result = usePartitionedBadges( + nodeData({ badges: [badge('x')] }) + ).value + expect(result.hasComfyBadge).toBe(true) + }) +}) + +describe('trackNodePrice', () => { + it('no-ops for a node without dynamic pricing', () => { + pricing.dynamic = false + trackNodePrice({ id: '1', type: 'Static', inputs: [] }) + + expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('1')) + expect(getWidgetMock).not.toHaveBeenCalled() + }) + + it('touches widget, input, and input-group pricing dependencies', () => { + pricing.dynamic = true + pricing.widgets = ['seed'] + pricing.inputs = ['model'] + pricing.groups = ['lora'] + let modelReads = 0 + let groupReads = 0 + let unrelatedReads = 0 + + trackNodePrice({ + id: '2', + type: 'Dynamic', + inputs: [ + inputSlot('model', () => { + modelReads += 1 + return 1 + }), + inputSlot('lora.0', () => { + groupReads += 1 + return 2 + }), + inputSlot('unrelated', () => { + unrelatedReads += 1 + return null + }) + ] + }) + + expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('2')) + expect(getWidgetMock).toHaveBeenCalled() + expect(modelReads).toBe(1) + expect(groupReads).toBe(1) + expect(unrelatedReads).toBe(0) + }) +}) diff --git a/src/scripts/metadata/parser.test.ts b/src/scripts/metadata/parser.test.ts new file mode 100644 index 00000000000..e19e6d8646b --- /dev/null +++ b/src/scripts/metadata/parser.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getFromWebmFile } from '@/scripts/metadata/ebml' +import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf' +import { getFromIsobmffFile } from '@/scripts/metadata/isobmff' +import { getDataFromJSON } from '@/scripts/metadata/json' +import { getMp3Metadata } from '@/scripts/metadata/mp3' +import { getOggMetadata } from '@/scripts/metadata/ogg' +import { getWorkflowDataFromFile } from '@/scripts/metadata/parser' +import { getSvgMetadata } from '@/scripts/metadata/svg' +import { + getAvifMetadata, + getFlacMetadata, + getLatentMetadata, + getPngMetadata, + getWebpMetadata +} from '@/scripts/pnginfo' + +vi.mock('@/scripts/metadata/ebml', () => ({ getFromWebmFile: vi.fn() })) +vi.mock('@/scripts/metadata/gltf', () => ({ getGltfBinaryMetadata: vi.fn() })) +vi.mock('@/scripts/metadata/isobmff', () => ({ getFromIsobmffFile: vi.fn() })) +vi.mock('@/scripts/metadata/json', () => ({ getDataFromJSON: vi.fn() })) +vi.mock('@/scripts/metadata/mp3', () => ({ getMp3Metadata: vi.fn() })) +vi.mock('@/scripts/metadata/ogg', () => ({ getOggMetadata: vi.fn() })) +vi.mock('@/scripts/metadata/svg', () => ({ getSvgMetadata: vi.fn() })) +vi.mock('@/scripts/pnginfo', () => ({ + getAvifMetadata: vi.fn(), + getFlacMetadata: vi.fn(), + getLatentMetadata: vi.fn(), + getPngMetadata: vi.fn(), + getWebpMetadata: vi.fn() +})) + +function file(type: string, name = 'file') { + return new File(['data'], name, { type }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getWorkflowDataFromFile', () => { + it('routes png/avif/mp3/ogg/webm to their parsers and returns the result', async () => { + vi.mocked(getPngMetadata).mockResolvedValue({ a: 1 } as never) + expect(await getWorkflowDataFromFile(file('image/png'))).toEqual({ a: 1 }) + expect(getPngMetadata).toHaveBeenCalled() + + await getWorkflowDataFromFile(file('image/avif')) + expect(getAvifMetadata).toHaveBeenCalled() + + await getWorkflowDataFromFile(file('audio/mpeg')) + expect(getMp3Metadata).toHaveBeenCalled() + + await getWorkflowDataFromFile(file('audio/ogg')) + expect(getOggMetadata).toHaveBeenCalled() + + await getWorkflowDataFromFile(file('video/webm')) + expect(getFromWebmFile).toHaveBeenCalled() + }) + + it('extracts workflow/prompt from webp, preferring lowercase keys', async () => { + vi.mocked(getWebpMetadata).mockResolvedValue({ + workflow: 'wf', + prompt: 'pr' + } as never) + expect(await getWorkflowDataFromFile(file('image/webp'))).toEqual({ + workflow: 'wf', + prompt: 'pr' + }) + }) + + it('falls back to capitalized webp keys when lowercase are absent', async () => { + vi.mocked(getWebpMetadata).mockResolvedValue({ + Workflow: 'WF', + Prompt: 'PR' + } as never) + expect(await getWorkflowDataFromFile(file('image/webp'))).toEqual({ + workflow: 'WF', + prompt: 'PR' + }) + }) + + it('handles both flac mime types and extracts workflow/prompt', async () => { + vi.mocked(getFlacMetadata).mockResolvedValue({ workflow: 'w' } as never) + expect(await getWorkflowDataFromFile(file('audio/flac'))).toEqual({ + workflow: 'w', + prompt: undefined + }) + expect(await getWorkflowDataFromFile(file('audio/x-flac'))).toEqual({ + workflow: 'w', + prompt: undefined + }) + }) + + it('falls back to capitalized flac keys when lowercase are absent', async () => { + vi.mocked(getFlacMetadata).mockResolvedValue({ + Workflow: 'WF', + Prompt: 'PR' + } as never) + expect(await getWorkflowDataFromFile(file('audio/flac'))).toEqual({ + workflow: 'WF', + prompt: 'PR' + }) + }) + + it('routes isobmff by mime type and by file extension', async () => { + await getWorkflowDataFromFile(file('video/mp4')) + await getWorkflowDataFromFile(file('video/quicktime')) + await getWorkflowDataFromFile(file('video/x-m4v')) + await getWorkflowDataFromFile(file('', 'clip.mp4')) + await getWorkflowDataFromFile(file('', 'clip.mov')) + await getWorkflowDataFromFile(file('', 'clip.m4v')) + expect(getFromIsobmffFile).toHaveBeenCalledTimes(6) + }) + + it('routes svg and gltf by mime type or extension', async () => { + await getWorkflowDataFromFile(file('image/svg+xml')) + await getWorkflowDataFromFile(file('', 'icon.svg')) + expect(getSvgMetadata).toHaveBeenCalledTimes(2) + + await getWorkflowDataFromFile(file('model/gltf-binary')) + await getWorkflowDataFromFile(file('', 'model.glb')) + expect(getGltfBinaryMetadata).toHaveBeenCalledTimes(2) + }) + + it('routes latent/safetensors and json by extension or mime type', async () => { + await getWorkflowDataFromFile(file('', 'x.latent')) + await getWorkflowDataFromFile(file('', 'x.safetensors')) + expect(getLatentMetadata).toHaveBeenCalledTimes(2) + + await getWorkflowDataFromFile(file('application/json')) + await getWorkflowDataFromFile(file('', 'x.json')) + expect(getDataFromJSON).toHaveBeenCalledTimes(2) + }) + + it('returns undefined for an unrecognized file', async () => { + expect( + await getWorkflowDataFromFile(file('application/zip', 'a.zip')) + ).toBe(undefined) + }) +}) diff --git a/src/stores/aboutPanelStore.test.ts b/src/stores/aboutPanelStore.test.ts new file mode 100644 index 00000000000..95fa847447e --- /dev/null +++ b/src/stores/aboutPanelStore.test.ts @@ -0,0 +1,135 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AboutPageBadge } from '@/types/comfy' +import { useAboutPanelStore } from '@/stores/aboutPanelStore' + +interface SystemInfo { + comfyui_version?: string + installed_templates_version?: string + required_templates_version?: string +} + +const { dist, stats, exts } = vi.hoisted(() => ({ + dist: { isCloud: false, isDesktop: false }, + stats: { system: {} as SystemInfo }, + exts: { list: [] as { aboutPageBadges?: AboutPageBadge[] }[] } +})) + +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return dist.isCloud + }, + get isDesktop() { + return dist.isDesktop + } +})) + +vi.mock('@/composables/useExternalLink', () => ({ + useExternalLink: () => ({ + staticUrls: { + github: 'https://github.com/comfyanonymous/ComfyUI', + githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend', + comfyOrg: 'https://comfy.org', + discord: 'https://discord.com' + } + }) +})) + +vi.mock('@/utils/envUtil', () => ({ + electronAPI: () => ({ getComfyUIVersion: () => '9.9.9' }) +})) + +vi.mock('@/stores/extensionStore', () => ({ + useExtensionStore: () => ({ extensions: exts.list }) +})) + +vi.mock('@/stores/systemStatsStore', () => ({ + useSystemStatsStore: () => ({ systemStats: stats }) +})) + +function label(badges: AboutPageBadge[], includes: string) { + return badges.find((b) => b.label.includes(includes)) +} + +beforeEach(() => { + setActivePinia(createPinia()) + dist.isCloud = false + dist.isDesktop = false + stats.system = {} + exts.list = [] +}) + +describe('aboutPanelStore', () => { + it('builds the default desktop-less, non-cloud core badges', () => { + stats.system = { comfyui_version: 'abc1234' } + const store = useAboutPanelStore() + + const core = label(store.badges, 'ComfyUI ')! + expect(core.icon).toBe('pi pi-github') + expect(core.url).toContain('github.com/comfyanonymous') + expect(label(store.badges, 'ComfyUI_frontend')).toBeDefined() + expect(label(store.badges, 'Discord')).toBeDefined() + expect(label(store.badges, 'Templates')).toBeUndefined() + }) + + it('uses cloud url and icon for the core badge when running on cloud', () => { + dist.isCloud = true + const store = useAboutPanelStore() + + const core = label(store.badges, 'ComfyUI ')! + expect(core.icon).toBe('pi pi-cloud') + expect(core.url).toBe('https://comfy.org') + }) + + it('uses the electron-reported version label on desktop', () => { + dist.isDesktop = true + const store = useAboutPanelStore() + + expect(label(store.badges, 'ComfyUI v9.9.9')).toBeDefined() + }) + + it('adds a danger templates badge when the installed version is outdated', () => { + stats.system = { + installed_templates_version: '1.0.0', + required_templates_version: '1.1.0' + } + const store = useAboutPanelStore() + + const templates = label(store.badges, 'Templates v1.0.0')! + expect(templates.severity).toBe('danger') + }) + + it('adds a templates badge without severity when versions match', () => { + stats.system = { + installed_templates_version: '1.1.0', + required_templates_version: '1.1.0' + } + const store = useAboutPanelStore() + + const templates = label(store.badges, 'Templates v1.1.0')! + expect(templates.severity).toBeUndefined() + }) + + it('does not mark templates outdated when the required version is missing', () => { + stats.system = { + installed_templates_version: '1.1.0' + } + const store = useAboutPanelStore() + + const templates = label(store.badges, 'Templates v1.1.0')! + expect(templates.severity).toBeUndefined() + }) + + it('appends extension badges and tolerates extensions without any', () => { + exts.list = [ + { + aboutPageBadges: [{ label: 'My Ext', url: 'https://ext', icon: 'pi' }] + }, + {} // extension without aboutPageBadges -> ?? [] branch + ] + const store = useAboutPanelStore() + + expect(label(store.badges, 'My Ext')).toBeDefined() + }) +}) diff --git a/src/stores/actionBarButtonStore.test.ts b/src/stores/actionBarButtonStore.test.ts new file mode 100644 index 00000000000..f405c68b2e1 --- /dev/null +++ b/src/stores/actionBarButtonStore.test.ts @@ -0,0 +1,26 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useActionBarButtonStore } from '@/stores/actionBarButtonStore' +import { useExtensionStore } from '@/stores/extensionStore' + +describe('actionBarButtonStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('collects action bar buttons from registered extensions', () => { + const extensionStore = useExtensionStore() + const onClick = vi.fn() + extensionStore.registerExtension({ + name: 'buttons', + actionBarButtons: [{ icon: 'icon-[lucide--plus]', onClick }] + }) + extensionStore.registerExtension({ name: 'plain' }) + + const store = useActionBarButtonStore() + + expect(store.buttons).toEqual([{ icon: 'icon-[lucide--plus]', onClick }]) + }) +}) diff --git a/src/stores/appModeStore.test.ts b/src/stores/appModeStore.test.ts index bbc8d4c6c0d..0541037d84a 100644 --- a/src/stores/appModeStore.test.ts +++ b/src/stores/appModeStore.test.ts @@ -1,7 +1,7 @@ import { createTestingPinia } from '@pinia/testing' import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' -import { nextTick } from 'vue' +import { nextTick, reactive } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' @@ -56,9 +56,13 @@ vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({ resolveNode: mockResolveNode })) +const mockCanvas = vi.hoisted(() => ({ + state: undefined as { readOnly: boolean } | undefined +})) + vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({ - getCanvas: () => ({ read_only: false }) + getCanvas: () => ({ state: mockCanvas.state }) }) })) @@ -162,6 +166,7 @@ describe('appModeStore', () => { ChangeTracker.isLoadingGraph = false mockResolveNode.mockReturnValue(undefined) mockSettings.reset() + mockCanvas.state = undefined vi.mocked(app.rootGraph).nodes = [{ id: toNodeId(1) } as LGraphNode] workflowStore = useWorkflowStore() store = useAppModeStore() @@ -365,6 +370,83 @@ describe('appModeStore', () => { expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']]) }) + it('keeps canonical entity ids when the node still exists', () => { + const node1 = nodeWithWidgets(1, []) + vi.mocked(app.rootGraph).nodes = [node1] + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => + id === toNodeId(1) ? node1 : null + ) + + store.loadSelections({ + inputs: [[entityPrompt, 'prompt']] + }) + + expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']]) + }) + + it('drops canonical entity ids when their node is gone', () => { + vi.mocked(app.rootGraph).nodes = [] + vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null) + + store.loadSelections({ + inputs: [[entityPrompt, 'prompt']] + }) + + expect(store.selectedInputs).toEqual([]) + }) + + it('drops locator inputs when the widget does not resolve', () => { + const hostLocator = `${rootGraphId}:5` + const hostNode = fromAny({ + id: 5, + isSubgraphNode: () => false, + widgets: [{ name: 'other' }] + }) + vi.mocked(app.rootGraph).nodes = [hostNode] + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => + id === toNodeId(5) ? hostNode : null + ) + + store.loadSelections({ + inputs: [[hostLocator, 'prompt']] + }) + + expect(store.selectedInputs).toEqual([]) + }) + + it('drops malformed legacy input ids', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(app.rootGraph).nodes = [] + + store.loadSelections({ + inputs: [[fromAny(null), 'prompt']] + }) + + expect(store.selectedInputs).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('legacy selectedInput tuple'), + expect.objectContaining({ storedId: null, widgetName: 'prompt' }) + ) + warnSpy.mockRestore() + }) + + it('drops direct node inputs when the widget is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const node1 = nodeWithWidgets(1, []) + vi.mocked(app.rootGraph).nodes = [node1] + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => + id === toNodeId(1) ? node1 : null + ) + + store.loadSelections({ + inputs: [[1, 'prompt']] + }) + + expect(store.selectedInputs).toEqual([]) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + it('drops legacy entries whose widget no longer exists', () => { const node1 = nodeWithWidgets(1, ['prompt']) vi.mocked(app.rootGraph).nodes = [node1] @@ -399,6 +481,32 @@ describe('appModeStore', () => { expect(store.selectedOutputs).toEqual([toNodeId(1)]) }) + it('drops malformed output ids on load', () => { + store.loadSelections({ + outputs: [fromAny('')] + }) + + expect(store.selectedOutputs).toEqual([]) + }) + + it('drops legacy subgraph input slots without widget ids', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hostNode = Object.assign(Object.create(SubgraphNode.prototype), { + id: 5, + inputs: [{ name: 'Prompt' }] + }) + vi.mocked(app.rootGraph).nodes = [hostNode] + vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null) + + store.loadSelections({ + inputs: [[1, 'prompt']] + }) + + expect(store.selectedInputs).toEqual([]) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + it('reloads selections on configured event', async () => { const node1 = nodeWithWidgets(1, ['seed']) @@ -481,7 +589,7 @@ describe('appModeStore', () => { expect( store.pruneLinearData({ inputs: [[1, 'seed']], - outputs: [toNodeId(1)] + outputs: [toNodeId(1), fromAny('')] }) ).toEqual({ inputs: [[1, 'seed']], @@ -641,6 +749,17 @@ describe('appModeStore', () => { expect(originalRootGraph.extra.linearData).toEqual(dataBefore) }) + it('does not write while graph loading is in progress', async () => { + workflowStore.activeWorkflow = createBuilderWorkflow() + ChangeTracker.isLoadingGraph = true + await nextTick() + + store.selectedOutputs.push(toNodeId(1)) + await nextTick() + + expect(app.rootGraph.extra.linearData).toBeUndefined() + }) + it('calls captureCanvasState when input is selected', async () => { const workflow = createBuilderWorkflow() workflowStore.activeWorkflow = workflow @@ -755,6 +874,24 @@ describe('appModeStore', () => { expect(store.selectedInputs).toEqual([[promptEntity, 'prompt']]) }) + + it('ignores widgets without ids', () => { + store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt']) + + store.removeSelectedInput(fromAny({})) + + expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']]) + }) + + it('ignores missing input ids', () => { + store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt']) + + store.removeSelectedInput( + fromAny({ widgetId: 'g:2:prompt' }) + ) + + expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']]) + }) }) describe('autoEnableVueNodes', () => { @@ -819,6 +956,47 @@ describe('appModeStore', () => { expect.anything() ) }) + + it('does not enable Vue nodes after leaving select mode', async () => { + mockSettings.store['Comfy.VueNodes.Enabled'] = false + workflowStore.activeWorkflow = createBuilderWorkflow('graph') + + store.enterBuilder() + await nextTick() + mockSettings.set.mockClear() + store.exitBuilder() + await nextTick() + + expect(mockSettings.set).not.toHaveBeenCalled() + }) + }) + + describe('read only canvas sync', () => { + it('keeps canvas read-only while in select mode', async () => { + mockCanvas.state = reactive({ readOnly: false }) + workflowStore.activeWorkflow = createBuilderWorkflow('graph') + + store.enterBuilder() + await nextTick() + mockCanvas.state.readOnly = false + await nextTick() + + expect(mockCanvas.state.readOnly).toBe(true) + }) + + it('stops enforcing read-only after leaving select mode', async () => { + mockCanvas.state = reactive({ readOnly: false }) + workflowStore.activeWorkflow = createBuilderWorkflow('graph') + + store.enterBuilder() + await nextTick() + store.exitBuilder() + await nextTick() + mockCanvas.state.readOnly = false + await nextTick() + + expect(mockCanvas.state.readOnly).toBe(false) + }) }) describe('legacy selectedInput tuple migration', () => { @@ -907,6 +1085,121 @@ describe('appModeStore', () => { ]) }) + it('drops direct root-node widgets that cannot produce an entity id', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sourceNodeId = 42 + const sourceWidgetName = 'text' + const rootNode = fromAny({ + id: sourceNodeId, + widgets: [{ name: sourceWidgetName }] + }) + vi.mocked(app.rootGraph).id = rootGraphId + vi.mocked(app.rootGraph).nodes = [rootNode] + vi.mocked(app.rootGraph).getNodeById = vi.fn( + (id: SerializedNodeId | null | undefined) => + id == sourceNodeId ? rootNode : null + ) + + const result = store.pruneLinearData({ + inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]], + outputs: [] + }) + + expect(result.inputs).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('legacy selectedInput tuple'), + expect.objectContaining({ + storedId: sourceNodeId, + widgetName: sourceWidgetName + }) + ) + warnSpy.mockRestore() + }) + + it('drops promoted inputs whose source target no longer matches', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const subgraphInputName = 'Prompt' + const sourceWidgetName = 'text' + + const subgraph = createTestSubgraph({ + inputs: [{ name: subgraphInputName, type: 'STRING' }] + }) + const interior = new LGraphNodeClass('Interior') + const interiorInput = interior.addInput(subgraphInputName, 'STRING') + interior.addWidget('string', sourceWidgetName, '', () => undefined) + interiorInput.widget = { name: sourceWidgetName } + subgraph.add(interior) + subgraph.inputNode.slots[0].connect(interiorInput, interior) + + const host = createTestSubgraphNode(subgraph, { id: 5 }) + const rootGraph = host.graph as LGraph + rootGraph.add(host) + host._internalConfigureAfterSlots() + + vi.mocked(app.rootGraph).id = rootGraph.id + vi.mocked(app.rootGraph).nodes = rootGraph.nodes + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => + rootGraph.getNodeById(id) + ) + + const result = store.pruneLinearData({ + inputs: [[interior.id, 'other-widget', { height: 120 }]], + outputs: [] + }) + + expect(result.inputs).toEqual([]) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('drops legacy inputs when multiple promoted inputs match', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const subgraphInputName = 'Prompt' + const sourceWidgetName = 'text' + + const subgraph = createTestSubgraph({ + inputs: [{ name: subgraphInputName, type: 'STRING' }] + }) + const interior = new LGraphNodeClass('Interior') + const interiorInput = interior.addInput(subgraphInputName, 'STRING') + interior.addWidget('string', sourceWidgetName, '', () => undefined) + interiorInput.widget = { name: sourceWidgetName } + subgraph.add(interior) + subgraph.inputNode.slots[0].connect(interiorInput, interior) + + const firstHost = createTestSubgraphNode(subgraph, { id: 5 }) + const rootGraph = firstHost.graph as LGraph + const secondHost = createTestSubgraphNode(subgraph, { + id: 6, + parentGraph: rootGraph + }) + rootGraph.add(firstHost) + rootGraph.add(secondHost) + firstHost._internalConfigureAfterSlots() + secondHost._internalConfigureAfterSlots() + + vi.mocked(app.rootGraph).id = rootGraph.id + vi.mocked(app.rootGraph).nodes = rootGraph.nodes + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => + rootGraph.getNodeById(id) + ) + + const result = store.pruneLinearData({ + inputs: [[interior.id, sourceWidgetName, { height: 120 }]], + outputs: [] + }) + + expect(result.inputs).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('ambiguous legacy selectedInput tuple'), + expect.objectContaining({ + storedId: interior.id, + widgetName: sourceWidgetName + }) + ) + warnSpy.mockRestore() + }) + it('warns and drops a tuple whose target widget no longer resolves', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.mocked(app.rootGraph).id = rootGraphId diff --git a/src/stores/assetDownloadStore.test.ts b/src/stores/assetDownloadStore.test.ts index fd428c36eb9..994bf77d4fb 100644 --- a/src/stores/assetDownloadStore.test.ts +++ b/src/stores/assetDownloadStore.test.ts @@ -126,6 +126,19 @@ describe('useAssetDownloadStore', () => { }) }) + it('keeps the first placeholder when the same task is tracked twice', () => { + const store = useAssetDownloadStore() + + store.trackDownload('task-123', 'checkpoints', 'first.safetensors') + store.trackDownload('task-123', 'loras', 'second.safetensors') + + expect(store.downloadList).toHaveLength(1) + expect(store.downloadList[0]).toMatchObject({ + modelType: 'checkpoints', + assetName: 'first.safetensors' + }) + }) + it('handles out-of-order messages where completed arrives before progress', () => { const store = useAssetDownloadStore() @@ -179,6 +192,19 @@ describe('useAssetDownloadStore', () => { expect(store.finishedDownloads[0].status).toBe('completed') }) + it('skips polling when active downloads have fresh progress', async () => { + const store = useAssetDownloadStore() + + dispatch(createDownloadMessage({ status: 'running' })) + await vi.advanceTimersByTimeAsync(9_999) + dispatch(createDownloadMessage({ status: 'running', progress: 75 })) + await vi.advanceTimersByTimeAsync(1) + + expect(taskService.getTask).not.toHaveBeenCalled() + expect(store.activeDownloads).toHaveLength(1) + expect(store.activeDownloads[0].progress).toBe(75) + }) + it('polls and marks failed downloads', async () => { const store = useAssetDownloadStore() @@ -311,5 +337,22 @@ describe('useAssetDownloadStore', () => { expect(store.sessionDownloadCount).toBe(0) expect(store.isDownloadedThisSession('asset-456')).toBe(false) }) + + it('does not acknowledge unrelated completed downloads', () => { + const store = useAssetDownloadStore() + + dispatch( + createDownloadMessage({ + status: 'completed', + progress: 100, + asset_id: 'asset-456' + }) + ) + + store.acknowledgeAsset('other-asset') + + expect(store.sessionDownloadCount).toBe(1) + expect(store.isDownloadedThisSession('asset-456')).toBe(true) + }) }) }) diff --git a/src/stores/assetExportStore.test.ts b/src/stores/assetExportStore.test.ts new file mode 100644 index 00000000000..0164e8b37e2 --- /dev/null +++ b/src/stores/assetExportStore.test.ts @@ -0,0 +1,300 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as VueUse from '@vueuse/core' + +import type { AssetExportWsMessage } from '@/schemas/apiSchema' +import { api } from '@/scripts/api' +import type { TaskId } from '@/platform/tasks/services/taskService' +import { useAssetExportStore } from '@/stores/assetExportStore' + +const { getExportDownloadUrl, getTask, toastAdd, intervalState } = vi.hoisted( + () => ({ + getExportDownloadUrl: vi.fn(), + getTask: vi.fn(), + toastAdd: vi.fn(), + intervalState: { cb: null as null | (() => void) } + }) +) + +vi.mock('@vueuse/core', async (importOriginal) => ({ + ...(await importOriginal()), + useIntervalFn: (cb: () => void) => { + intervalState.cb = cb + return { pause: vi.fn(), resume: vi.fn() } + } +})) + +vi.mock('@/scripts/api', () => ({ + api: { addEventListener: vi.fn() } +})) + +vi.mock('@/platform/assets/services/assetService', () => ({ + assetService: { getExportDownloadUrl } +})) + +vi.mock('@/platform/tasks/services/taskService', () => ({ + taskService: { getTask } +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ add: toastAdd }) +})) + +vi.mock('@/i18n', () => ({ + t: (key: string) => key +})) + +function wsMessage( + over: Partial = {} +): AssetExportWsMessage { + return { + task_id: 'task-1', + export_name: 'export.zip', + assets_total: 10, + assets_attempted: 5, + assets_failed: 0, + bytes_total: 1000, + bytes_processed: 500, + progress: 0.5, + status: 'running', + ...over + } +} + +const taskId = (id: string) => id as TaskId + +/** + * Build a store and an `emit` bound to the real `asset_export` listener the + * store registers on `api`, so tests drive the state machine through its + * actual entry point rather than a private method. + */ +function setup() { + const store = useAssetExportStore() + const entry = vi + .mocked(api.addEventListener) + .mock.calls.find((c) => c[0] === 'asset_export') + const handler = entry![1] as (e: { detail: AssetExportWsMessage }) => void + const emit = (msg: AssetExportWsMessage) => handler({ detail: msg }) + // Run the polling tick that `useIntervalFn` would normally fire, and let its + // async work settle. + const runPoll = async () => { + intervalState.cb?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + } + return { store, emit, runPoll } +} + +const STALE_AGO_MS = 20_000 + +beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(api.addEventListener).mockClear() + getExportDownloadUrl + .mockReset() + .mockResolvedValue({ url: 'https://example.com/export.zip' }) + getTask.mockReset() + toastAdd.mockReset() +}) + +describe('assetExportStore', () => { + it('tracks a new export as created and is idempotent', () => { + const { store } = setup() + + store.trackExport(taskId('t1')) + store.trackExport(taskId('t1')) + + expect(store.exportList).toHaveLength(1) + expect(store.exportList[0].status).toBe('created') + expect(store.hasExports).toBe(true) + expect(store.hasActiveExports).toBe(true) + }) + + it('separates active from finished exports by status', () => { + const { store, emit } = setup() + + emit(wsMessage({ task_id: 'running', status: 'running' })) + emit( + wsMessage({ task_id: 'failed', status: 'failed', export_name: 'f.zip' }) + ) + + expect(store.activeExports.map((e) => e.taskId)).toEqual(['running']) + expect(store.finishedExports.map((e) => e.taskId)).toEqual(['failed']) + }) + + it('updates an export from successive websocket messages', () => { + const { store, emit } = setup() + + emit(wsMessage({ progress: 0.5, status: 'running' })) + emit(wsMessage({ progress: 0.9, status: 'running' })) + + expect(store.exportList).toHaveLength(1) + expect(store.exportList[0].progress).toBe(0.9) + }) + + it('ignores updates for an export already completed and downloaded', async () => { + const { store, emit } = setup() + + emit(wsMessage({ status: 'completed' })) + await Promise.resolve() + const triggeredCalls = getExportDownloadUrl.mock.calls.length + + // A late 'running' message must not revive a completed+downloaded export + emit(wsMessage({ status: 'running', progress: 0.1 })) + + expect(store.exportList[0].status).toBe('completed') + expect(getExportDownloadUrl).toHaveBeenCalledTimes(triggeredCalls) + }) + + it('falls back to the prior export name when a message omits it', async () => { + const { store, emit } = setup() + + emit(wsMessage({ status: 'running', progress: 0.4 })) + emit( + wsMessage({ status: 'running', export_name: undefined, progress: 0.6 }) + ) + + expect(store.exportList[0].exportName).toBe('export.zip') + }) + + it('falls back to a blank export name when no message has named it', () => { + const { store, emit } = setup() + + emit(wsMessage({ export_name: undefined, status: 'running' })) + + expect(store.exportList[0].exportName).toBe('') + }) + + it('triggers a download for a named export and clears prior errors', async () => { + const { store, emit } = setup() + emit(wsMessage({ status: 'running' })) + const [exp] = store.exportList + + await store.triggerDownload(exp) + + expect(getExportDownloadUrl).toHaveBeenCalledWith('export.zip') + expect(exp.downloadTriggered).toBe(true) + expect(exp.downloadError).toBeUndefined() + }) + + it('does not re-trigger a download unless forced', async () => { + const { store, emit } = setup() + emit(wsMessage({ status: 'running' })) + const [exp] = store.exportList + exp.downloadTriggered = true + + await store.triggerDownload(exp) + expect(getExportDownloadUrl).not.toHaveBeenCalled() + + await store.triggerDownload(exp, true) + expect(getExportDownloadUrl).toHaveBeenCalledTimes(1) + }) + + it('records a download error and surfaces a toast on failure', async () => { + getExportDownloadUrl.mockRejectedValueOnce(new Error('network down')) + const { store, emit } = setup() + emit(wsMessage({ status: 'running' })) + const [exp] = store.exportList + + await store.triggerDownload(exp) + + expect(exp.downloadError).toBe('network down') + expect(exp.downloadTriggered).toBe(false) + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }) + ) + }) + + it('records a string download error', async () => { + getExportDownloadUrl.mockRejectedValueOnce('offline') + const { store, emit } = setup() + emit(wsMessage({ status: 'running' })) + const [exp] = store.exportList + + await store.triggerDownload(exp) + + expect(exp.downloadError).toBe('offline') + }) + + it('clears finished exports while keeping active ones', () => { + const { store, emit } = setup() + emit(wsMessage({ task_id: 'a', status: 'running' })) + emit(wsMessage({ task_id: 'b', status: 'failed', export_name: 'b.zip' })) + + store.clearFinishedExports() + + expect(store.exportList.map((e) => e.taskId)).toEqual(['a']) + }) + + it('does not poll when no active export is stale', async () => { + const { emit, runPoll } = setup() + emit(wsMessage({ status: 'running' })) + + await runPoll() + + expect(getTask).not.toHaveBeenCalled() + }) + + it('reconciles a stale export from the task service result', async () => { + const { store, emit, runPoll } = setup() + emit(wsMessage({ status: 'running' })) + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS + getTask.mockResolvedValue({ + status: 'completed', + result: { export_name: 'reconciled.zip', assets_total: 10 } + }) + + await runPoll() + + expect(getTask).toHaveBeenCalledWith('task-1') + expect(store.exportList[0].status).toBe('completed') + expect(store.exportList[0].exportName).toBe('reconciled.zip') + }) + + it('leaves a stale export active when the task is still running', async () => { + const { store, emit, runPoll } = setup() + emit(wsMessage({ status: 'running' })) + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS + getTask.mockResolvedValue({ status: 'running' }) + + await runPoll() + + expect(store.exportList[0].status).toBe('running') + }) + + it('reconciles a stale failed export using existing counters', async () => { + const { store, emit, runPoll } = setup() + emit( + wsMessage({ + assets_attempted: 4, + assets_failed: 1, + status: 'running' + }) + ) + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS + getTask.mockResolvedValue({ + status: 'failed', + result: { error: 'failed in result' } + }) + + await runPoll() + + expect(store.exportList[0]).toMatchObject({ + assetsAttempted: 4, + assetsFailed: 1, + error: 'failed in result', + status: 'failed' + }) + }) + + it('leaves a stale export untouched when the task lookup fails', async () => { + const { store, emit, runPoll } = setup() + emit(wsMessage({ status: 'running' })) + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS + getTask.mockRejectedValue(new Error('task not found')) + + await runPoll() + + expect(store.exportList[0].status).toBe('running') + }) +}) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 98806b3330b..4700da16ff7 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, watch } from 'vue' @@ -11,6 +12,7 @@ import type { } from '@/platform/assets/schemas/assetSchema' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import { assetService } from '@/platform/assets/services/assetService' +import { useAssetDownloadStore } from '@/stores/assetDownloadStore' // Mock the api module vi.mock('@/scripts/api', () => ({ @@ -48,6 +50,11 @@ vi.mock('@/platform/distribution/types', () => ({ } })) +// Node types whose category lookup should throw, simulating a refresh failure +const mockCategoryLookupFailures = vi.hoisted(() => ({ + nodeTypes: new Set() +})) + // Mock modelToNodeStore with proper node providers and category lookups vi.mock('@/stores/modelToNodeStore', () => ({ useModelToNodeStore: () => ({ @@ -69,6 +76,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({ return providers[category] ?? [] }), getCategoryForNodeType: vi.fn((nodeType: string) => { + if (mockCategoryLookupFailures.nodeTypes.has(nodeType)) { + throw new Error(`No category registered for node type: ${nodeType}`) + } const nodeToCategory: Record = { CheckpointLoaderSimple: 'checkpoints', ImageOnlyCheckpointLoader: 'checkpoints', @@ -96,6 +106,10 @@ const mockOutputOverrides = vi.hoisted(() => ({ value: null as MockOutput[] | null })) +const mockAssetMapperOptions = vi.hoisted(() => ({ + omitCreatedAtForIds: new Set() +})) + // Mock TaskItemImpl const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio']) @@ -169,11 +183,14 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({ })), mapTaskOutputToAssetItem: vi.fn((task, output) => { const index = parseInt(task.jobId.split('_')[1]) || 0 + const createdAt = new Date(Date.now() - index * 1000).toISOString() return { id: task.jobId, name: output.filename, size: 0, - created_at: new Date(Date.now() - index * 1000).toISOString(), + ...(!mockAssetMapperOptions.omitCreatedAtForIds.has(task.jobId) && { + created_at: createdAt + }), tags: ['output'], preview_url: output.url, user_metadata: {} @@ -205,6 +222,7 @@ describe('assetsStore - Refactored (Option A)', () => { setActivePinia(createTestingPinia({ stubActions: false })) store = useAssetsStore() vi.clearAllMocks() + mockAssetMapperOptions.omitCreatedAtForIds.clear() }) describe('Initial Load', () => { @@ -272,6 +290,17 @@ describe('assetsStore - Refactored (Option A)', () => { 'prompt_2' ]) }) + + it('should skip unfinished jobs and completed jobs without previews', async () => { + vi.mocked(api.getHistory).mockResolvedValue([ + { ...createMockJobItem(0), status: 'in_progress' }, + { ...createMockJobItem(1), preview_output: undefined } + ]) + + await store.updateHistory() + + expect(store.historyAssets).toEqual([]) + }) }) describe('Pagination', () => { @@ -328,6 +357,46 @@ describe('assetsStore - Refactored (Option A)', () => { expect(uniqueAssetIds.size).toBe(store.historyAssets.length) }) + it('should insert newer paginated items in sorted order', async () => { + vi.mocked(api.getHistory).mockResolvedValueOnce( + Array.from({ length: 200 }, (_, i) => createMockJobItem(i)) + ) + await store.updateHistory() + + vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)]) + await store.loadMoreHistory() + + expect(store.historyAssets[0].id).toBe('prompt_-1') + }) + + it('sorts paginated items when the incoming asset has no timestamp', async () => { + vi.mocked(api.getHistory).mockResolvedValueOnce( + Array.from({ length: 200 }, (_, i) => createMockJobItem(i)) + ) + await store.updateHistory() + mockAssetMapperOptions.omitCreatedAtForIds.add('prompt_200') + vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(200)]) + + await store.loadMoreHistory() + + expect(store.historyAssets.at(-1)?.id).toBe('prompt_200') + }) + + it('sorts paginated items when an existing asset has no timestamp', async () => { + for (let i = 0; i < 200; i++) { + mockAssetMapperOptions.omitCreatedAtForIds.add(`prompt_${i}`) + } + vi.mocked(api.getHistory).mockResolvedValueOnce( + Array.from({ length: 200 }, (_, i) => createMockJobItem(i)) + ) + await store.updateHistory() + vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)]) + + await store.loadMoreHistory() + + expect(store.historyAssets[0].id).toBe('prompt_-1') + }) + it('should stop loading when no more items', async () => { // First batch - less than BATCH_SIZE const firstBatch = Array.from({ length: 50 }, (_, i) => @@ -494,6 +563,29 @@ describe('assetsStore - Refactored (Option A)', () => { expect(store.historyLoading).toBe(false) expect(store.historyError).toBe(error) }) + + it('should preserve existing history when refresh fails', async () => { + vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(0)]) + await store.updateHistory() + + const error = new Error('API error') + vi.mocked(api.getHistory).mockRejectedValueOnce(error) + + await store.updateHistory() + + expect(store.historyAssets).toHaveLength(1) + expect(store.historyError).toBe(error) + }) + + it('should keep empty history when loadMore fails before any load', async () => { + const error = new Error('API error') + vi.mocked(api.getHistory).mockRejectedValueOnce(error) + + await store.loadMoreHistory() + + expect(store.historyAssets).toEqual([]) + expect(store.historyError).toBe(error) + }) }) describe('Memory Management', () => { @@ -778,6 +870,7 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) mockIsCloud.value = true + mockCategoryLookupFailures.nodeTypes.clear() vi.clearAllMocks() }) @@ -924,6 +1017,43 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { vi.mocked(assetService.getAssetsForNodeType) ).toHaveBeenCalledTimes(2) }) + + it('ignores a model response after the category is invalidated', async () => { + const store = useAssetsStore() + let resolveFetch!: (assets: AssetItem[]) => void + vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce( + new Promise((resolve) => { + resolveFetch = resolve + }) + ) + + const request = store.updateModelsForNodeType('CheckpointLoaderSimple') + store.invalidateCategory('checkpoints') + resolveFetch([createMockAsset('stale-response')]) + await request + + expect(store.getAssets('CheckpointLoaderSimple')).toEqual([]) + }) + + it('ignores a model rejection after the category is invalidated', async () => { + const store = useAssetsStore() + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + let rejectFetch!: (error: Error) => void + vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectFetch = reject + }) + ) + + const request = store.updateModelsForNodeType('CheckpointLoaderSimple') + store.invalidateCategory('checkpoints') + rejectFetch(new Error('stale rejection')) + await request + + expect(store.getError('CheckpointLoaderSimple')).toBeUndefined() + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) }) describe('shallowReactive state reactivity', () => { @@ -966,6 +1096,10 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { it('should return empty array for unknown node types', () => { const store = useAssetsStore() expect(store.getAssets('UnknownNodeType')).toEqual([]) + expect(store.isModelLoading('UnknownNodeType')).toBe(false) + expect(store.getError('UnknownNodeType')).toBeUndefined() + expect(store.hasMore('UnknownNodeType')).toBe(false) + expect(store.hasAssetKey('UnknownNodeType')).toBe(false) }) it('should not fetch for unknown node types', async () => { @@ -975,6 +1109,63 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { vi.mocked(assetService.getAssetsForNodeType) ).not.toHaveBeenCalled() }) + + it('should refresh an already loaded category', async () => { + const store = useAssetsStore() + const nodeType = 'CheckpointLoaderSimple' + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + createMockAsset('first') + ]) + await store.updateModelsForNodeType(nodeType) + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + createMockAsset('second') + ]) + await store.updateModelsForNodeType(nodeType) + + expect(store.getAssets(nodeType).map((asset) => asset.id)).toEqual([ + 'second' + ]) + }) + + it('reports hasMore for a loaded category', async () => { + const store = useAssetsStore() + const nodeType = 'CheckpointLoaderSimple' + + expect(store.hasMore(nodeType)).toBe(false) + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + createMockAsset('only-page') + ]) + + await store.updateModelsForNodeType(nodeType) + + expect(store.hasMore(nodeType)).toBe(false) + }) + + it('should record model loading errors', async () => { + const store = useAssetsStore() + const error = new Error('model fetch failed') + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce(error) + + await store.updateModelsForNodeType('CheckpointLoaderSimple') + + expect(store.getError('CheckpointLoaderSimple')).toBe(error) + expect(store.isModelLoading('CheckpointLoaderSimple')).toBe(false) + consoleSpy.mockRestore() + }) + + it('should wrap non-error model loading failures', async () => { + const store = useAssetsStore() + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce('boom') + + await store.updateModelsForNodeType('CheckpointLoaderSimple') + + expect(store.getError('CheckpointLoaderSimple')?.message).toBe('boom') + consoleSpy.mockRestore() + }) }) describe('invalidateCategory', () => { @@ -1129,7 +1320,177 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { }) }) + describe('completed download refresh', () => { + it('refreshes provider and tag caches for the completed model type', async () => { + const store = useAssetsStore() + const downloadStore = useAssetDownloadStore() + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([]) + vi.mocked(assetService.getAssetsByTag).mockResolvedValue([]) + + downloadStore.lastCompletedDownload = { + taskId: 'task-1', + modelType: 'checkpoints', + timestamp: 1 + } + + await vi.waitFor(() => + expect(assetService.getAssetsByTag).toHaveBeenCalledWith( + 'models', + true, + expect.objectContaining({ limit: 500, offset: 0 }) + ) + ) + + expect(assetService.getAssetsForNodeType).toHaveBeenCalledWith( + 'CheckpointLoaderSimple', + expect.objectContaining({ limit: 500, offset: 0 }) + ) + expect(assetService.getAssetsForNodeType).toHaveBeenCalledTimes(1) + expect(assetService.getAssetsByTag).toHaveBeenCalledWith( + 'checkpoints', + true, + expect.objectContaining({ limit: 500, offset: 0 }) + ) + expect(store.hasCategory('tag:models')).toBe(true) + }) + + it('settles the batch when one provider refresh rejects', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + mockCategoryLookupFailures.nodeTypes.add('ImageOnlyCheckpointLoader') + + useAssetsStore() + const downloadStore = useAssetDownloadStore() + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([]) + vi.mocked(assetService.getAssetsByTag).mockResolvedValue([]) + + downloadStore.lastCompletedDownload = { + taskId: 'task-2', + modelType: 'checkpoints', + timestamp: 2 + } + + await vi.waitFor(() => + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('ImageOnlyCheckpointLoader'), + expect.any(Error) + ) + ) + + expect(assetService.getAssetsForNodeType).toHaveBeenCalledWith( + 'CheckpointLoaderSimple', + expect.objectContaining({ limit: 500, offset: 0 }) + ) + expect(assetService.getAssetsByTag).toHaveBeenCalledWith( + 'models', + true, + expect.objectContaining({ limit: 500, offset: 0 }) + ) + + consoleErrorSpy.mockRestore() + }) + }) + describe('updateAssetMetadata optimistic cache', () => { + it('still writes metadata when a cache key is unresolved', async () => { + const store = useAssetsStore() + const original = { + ...createMockAsset('opt-unknown'), + user_metadata: { note: 'before' } as Record + } + vi.mocked(assetService.updateAsset).mockResolvedValueOnce({ + ...original, + user_metadata: { note: 'after' } + }) + + await store.updateAssetMetadata( + original, + { note: 'after' }, + 'UnknownNodeType' + ) + + expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith( + 'opt-unknown', + { user_metadata: { note: 'after' } } + ) + }) + + it('still updates the server when the asset is not cached', async () => { + const store = useAssetsStore() + const original = { + ...createMockAsset('opt-missing'), + user_metadata: { note: 'before' } as Record + } + vi.mocked(assetService.updateAsset).mockResolvedValueOnce({ + ...original, + user_metadata: { note: 'server' } + }) + + await store.updateAssetMetadata(original, { note: 'after' }) + + expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith( + 'opt-missing', + { user_metadata: { note: 'after' } } + ) + }) + + it('still updates the server when a resolved cache key has not loaded yet', async () => { + const store = useAssetsStore() + const original = { + ...createMockAsset('opt-unloaded'), + user_metadata: { note: 'before' } as Record + } + vi.mocked(assetService.updateAsset).mockResolvedValueOnce({ + ...original, + user_metadata: { note: 'server' } + }) + + await store.updateAssetMetadata( + original, + { note: 'after' }, + 'CheckpointLoaderSimple' + ) + + expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith( + 'opt-unloaded', + { user_metadata: { note: 'after' } } + ) + }) + + it('leaves unrelated cached assets alone during optimistic metadata update', async () => { + const store = useAssetsStore() + const cached = { + ...createMockAsset('opt-cached'), + user_metadata: { note: 'cached' } as Record + } + const missing = { + ...createMockAsset('opt-missing-from-cache'), + user_metadata: { note: 'before' } as Record + } + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + cached + ]) + await store.updateModelsForNodeType('CheckpointLoaderSimple') + vi.mocked(assetService.updateAsset).mockResolvedValueOnce({ + ...missing, + user_metadata: { note: 'server' } + }) + + await store.updateAssetMetadata( + missing, + { note: 'after' }, + 'CheckpointLoaderSimple' + ) + + expect( + store.getAssets('CheckpointLoaderSimple')[0].user_metadata + ).toEqual({ + note: 'cached' + }) + }) + it('reflects the server response in the cache after a successful update', async () => { const store = useAssetsStore() const original = { @@ -1237,6 +1598,31 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { 'featured' ]) }) + + it('calls only the remove endpoint when there are no tags to add', async () => { + const store = useAssetsStore() + const asset = createMockAsset('tags-remove-only', ['models', 'archived']) + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + asset + ]) + await store.updateModelsForNodeType('CheckpointLoaderSimple') + + vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({ + total_tags: ['models'] + }) + + await store.updateAssetTags(asset, ['models'], 'CheckpointLoaderSimple') + + expect(vi.mocked(assetService.removeAssetTags)).toHaveBeenCalledWith( + 'tags-remove-only', + ['archived'] + ) + expect(vi.mocked(assetService.addAssetTags)).not.toHaveBeenCalled() + expect(store.getAssets('CheckpointLoaderSimple')[0].tags).toEqual([ + 'models' + ]) + }) }) describe('updateAssetTags partial-failure compensation', () => { @@ -1351,6 +1737,36 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { expect(store.hasCategory('tag:models')).toBe(false) }) + it('keeps unrelated tag caches when compensation fails with a cache key', async () => { + const store = useAssetsStore() + const asset = createMockAsset('tags-target-fail', ['models', 'loras']) + const otherAsset = createMockAsset('tags-other', ['models']) + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + asset + ]) + await store.updateModelsForNodeType('LoraLoader') + vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([otherAsset]) + await store.updateModelsForTag('models') + + vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({ + removed: ['loras'], + total_tags: ['models'] + }) + vi.mocked(assetService.addAssetTags) + .mockRejectedValueOnce(new Error('500 add failed')) + .mockRejectedValueOnce(new Error('503 compensation failed')) + + await store.updateAssetTags( + asset, + ['models', 'checkpoints'], + 'LoraLoader' + ) + + expect(store.hasCategory('loras')).toBe(false) + expect(store.hasCategory('tag:models')).toBe(true) + }) + it('does not attempt compensation when only the add was attempted', async () => { const store = useAssetsStore() const asset = createMockAsset('tags-add-only-fail', ['models']) @@ -1483,9 +1899,78 @@ describe('assetsStore - Deletion State and Input Mapping', () => { const store = useAssetsStore() expect(store.getInputName('unknown.png')).toBe('unknown.png') }) + + it('ignores input assets without hashes', async () => { + mockIsCloud.value = true + try { + setActivePinia(createTestingPinia({ stubActions: false })) + const store = useAssetsStore() + + vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([ + { + id: 'input-1', + name: 'plain.png', + tags: ['input'] + } + ]) + await store.updateInputs() + + expect(store.getInputName('plain.png')).toBe('plain.png') + } finally { + mockIsCloud.value = false + } + }) }) describe('updateInputs cloud routing', () => { + it('reads input files from the internal API when isCloud is false', async () => { + const fetchMock = vi.fn().mockResolvedValue( + fromAny({ + ok: true, + json: async () => ['input-a.png', 'input-b.png'] + }) + ) + vi.stubGlobal('fetch', fetchMock) + try { + const store = useAssetsStore() + + await store.updateInputs() + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/files/input', + { headers: { 'Comfy-User': 'test-user' } } + ) + expect(store.inputAssets.map((asset) => asset.name)).toEqual([ + 'input-a.png', + 'input-b.png' + ]) + } finally { + vi.unstubAllGlobals() + } + }) + + it('records internal input API failures', async () => { + const fetchMock = vi.fn().mockResolvedValue( + fromAny({ + ok: false + }) + ) + vi.stubGlobal('fetch', fetchMock) + try { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const store = useAssetsStore() + + await store.updateInputs() + + expect(store.inputError).toBeInstanceOf(Error) + consoleSpy.mockRestore() + } finally { + vi.unstubAllGlobals() + } + }) + it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => { mockIsCloud.value = true try { @@ -1586,6 +2071,18 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { expect(store.flatOutputHasMore).toBe(false) }) + it('does not load more flat outputs when there are no more pages', async () => { + vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce( + makePage([makeAsset('a1', 'one.png')]) + ) + + const store = useAssetsStore() + await store.updateFlatOutputs() + await store.loadMoreFlatOutputs() + + expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(1) + }) + it('threads the minted cursor into after on loadMore and omits offset', async () => { vi.mocked(assetService.getAssetsPageByTag) .mockResolvedValueOnce( @@ -1800,4 +2297,26 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1']) }) + + it('ignores concurrent load more calls while one is active', async () => { + vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce( + makePage([makeAsset('a1', 'f1.png')], { hasMore: true }) + ) + const store = useAssetsStore() + await store.updateFlatOutputs() + + let resolvePage!: (page: AssetResponse) => void + vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce( + new Promise((resolve) => { + resolvePage = resolve + }) + ) + + const first = store.loadMoreFlatOutputs() + const second = store.loadMoreFlatOutputs() + resolvePage(makePage([makeAsset('a2', 'f2.png')])) + await Promise.all([first, second]) + + expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 0a8b20e4b5e..2e63724caeb 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -837,30 +837,24 @@ export const useAssetsStore = defineStore('assets', () => { .getAllNodeProviders(modelType) .filter((provider) => provider.nodeDef?.name) - const nodeTypeUpdates = providers.map((provider) => - updateModelsForNodeType(provider.nodeDef.name).then( - () => provider.nodeDef.name - ) - ) + const nodeTypeRefreshes = providers.map((provider) => ({ + target: `node type "${provider.nodeDef.name}"`, + refresh: updateModelsForNodeType(provider.nodeDef.name) + })) // Also update by tag in case modal was opened with assetType - const tagUpdates = [ - updateModelsForTag(modelType), - updateModelsForTag('models') - ] - - const results = await Promise.allSettled([ - ...nodeTypeUpdates, - ...tagUpdates - ]) - - for (const result of results) { - if (result.status === 'rejected') { - console.error( - `Failed to refresh model cache for provider: ${result.reason}` - ) - } - } + const tagRefreshes = [modelType, 'models'].map((tag) => ({ + target: `tag "${tag}"`, + refresh: updateModelsForTag(tag) + })) + + await Promise.all( + [...nodeTypeRefreshes, ...tagRefreshes].map(({ target, refresh }) => + refresh.catch((error) => { + console.error(`Failed to refresh model cache for ${target}`, error) + }) + ) + ) } ) diff --git a/src/stores/authStore.test.ts b/src/stores/authStore.test.ts index 8a8d239387b..d8637231fb6 100644 --- a/src/stores/authStore.test.ts +++ b/src/stores/authStore.test.ts @@ -90,6 +90,7 @@ vi.mock('firebase/auth', async (importOriginal) => { onAuthStateChanged: vi.fn(), onIdTokenChanged: vi.fn(), signInWithPopup: vi.fn(), + sendPasswordResetEmail: vi.fn(), GoogleAuthProvider: class { addScope = vi.fn() setCustomParameters = vi.fn() @@ -99,7 +100,8 @@ vi.mock('firebase/auth', async (importOriginal) => { setCustomParameters = vi.fn() }, getAdditionalUserInfo: vi.fn(), - setPersistence: vi.fn().mockResolvedValue(undefined) + setPersistence: vi.fn().mockResolvedValue(undefined), + updatePassword: vi.fn() } }) @@ -127,6 +129,18 @@ vi.mock('@/composables/useFeatureFlags', () => ({ }) })) +const mockWorkspaceAuthStore = vi.hoisted(() => ({ + unifiedToken: null as string | null, + clearWorkspaceContext: vi.fn(), + mintAtLogin: vi.fn(), + getWorkspaceAuthHeader: vi.fn(), + getWorkspaceToken: vi.fn() +})) + +vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({ + useWorkspaceAuthStore: () => mockWorkspaceAuthStore +})) + // Mock apiKeyAuthStore const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null) vi.mock('@/stores/apiKeyAuthStore', () => ({ @@ -163,6 +177,9 @@ describe('useAuthStore', () => { mockFeatureFlags.teamWorkspacesEnabled = false mockFeatureFlags.unifiedCloudAuthEnabled = false + mockWorkspaceAuthStore.unifiedToken = null + mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null) + mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined) // Setup dialog service mock vi.mocked(useDialogService, { partial: true }).mockReturnValue({ @@ -275,6 +292,11 @@ describe('useAuthStore', () => { store.notifyTokenRefreshed() expect(store.tokenRefreshTrigger).toBe(1) }) + + it('ignores null ID token events', () => { + idTokenCallback?.(null) + expect(store.tokenRefreshTrigger).toBe(0) + }) }) it('should initialize with the current user', () => { @@ -292,6 +314,24 @@ describe('useAuthStore', () => { ) }) + it('mints workspace auth on cloud login and clears it on logout state', () => { + expect(mockWorkspaceAuthStore.mintAtLogin).toHaveBeenCalledOnce() + + authStateCallback(null) + + expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalledOnce() + }) + + it('does not mint workspace auth outside cloud', () => { + mockWorkspaceAuthStore.mintAtLogin.mockClear() + mockDistributionTypes.isCloud = false + + authStateCallback(mockUser) + + expect(mockWorkspaceAuthStore.mintAtLogin).not.toHaveBeenCalled() + mockDistributionTypes.isCloud = true + }) + it('should properly clean up error state between operations', async () => { // First, cause an error const mockError = new Error('Invalid password') @@ -349,6 +389,30 @@ describe('useAuthStore', () => { expect(store.loading).toBe(false) }) + it('tracks login when Firebase returns no email', async () => { + const userWithoutEmail = { ...mockUser, email: null } + vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({ + user: userWithoutEmail + } as Partial as UserCredential) + + await store.login('test@example.com', 'password') + + expect(mockTrackAuth).toHaveBeenCalledWith( + expect.objectContaining({ email: undefined }) + ) + }) + + it('fails customer creation when the signed-in user has no token yet', async () => { + authStateCallback(null) + vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({ + user: mockUser + } as Partial as UserCredential) + + await expect(store.login('test@example.com', 'password')).rejects.toThrow( + 'Cannot create customer: User not authenticated' + ) + }) + it('should handle concurrent login attempts correctly', async () => { // Set up multiple login promises const mockUserCredential = { user: mockUser } @@ -486,6 +550,19 @@ describe('useAuthStore', () => { ).rejects.toThrow() expect(mockUser.delete).not.toHaveBeenCalled() }) + + it('tracks registration when Firebase returns no email', async () => { + const userWithoutEmail = { ...mockUser, email: null } + vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({ + user: userWithoutEmail + } as Partial as UserCredential) + + await store.register('new@example.com', 'password') + + expect(mockTrackAuth).toHaveBeenCalledWith( + expect.objectContaining({ email: undefined }) + ) + }) }) describe('logout', () => { @@ -619,6 +696,54 @@ describe('useAuthStore', () => { const authHeader = await store.getAuthHeader() expect(authHeader).toBeNull() // Should fallback gracefully }) + + it('uses the unified cloud token when enabled', async () => { + mockFeatureFlags.unifiedCloudAuthEnabled = true + mockWorkspaceAuthStore.unifiedToken = 'unified-token' + + await expect(store.getAuthHeader()).resolves.toEqual({ + Authorization: 'Bearer unified-token' + }) + await expect(store.getAuthToken()).resolves.toBe('unified-token') + }) + + it('returns no unified auth when the unified token is missing', async () => { + mockFeatureFlags.unifiedCloudAuthEnabled = true + mockWorkspaceAuthStore.unifiedToken = null + + await expect(store.getAuthHeader()).resolves.toBeNull() + await expect(store.getAuthToken()).resolves.toBeUndefined() + }) + + it('prefers workspace auth when team workspaces are enabled', async () => { + mockFeatureFlags.teamWorkspacesEnabled = true + mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue({ + Authorization: 'Bearer workspace-header' + }) + mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue( + 'workspace-token' + ) + + await expect(store.getAuthHeader()).resolves.toEqual({ + Authorization: 'Bearer workspace-header' + }) + await expect(store.getAuthToken()).resolves.toBe('workspace-token') + }) + + it('falls back to Firebase when workspace auth is unavailable', async () => { + mockFeatureFlags.teamWorkspacesEnabled = true + mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null) + mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined) + + await expect(store.getAuthHeader()).resolves.toEqual({ + Authorization: 'Bearer mock-id-token' + }) + await expect(store.getAuthToken()).resolves.toBe('mock-id-token') + }) + + it('returns the Firebase token by default', async () => { + await expect(store.getAuthToken()).resolves.toBe('mock-id-token') + }) }) describe('social authentication', () => { @@ -804,6 +929,22 @@ describe('useAuthStore', () => { ) } ) + + it.for(['loginWithGoogle', 'loginWithGithub'] as const)( + '%s should track undefined email when Firebase returns no email', + async (method) => { + const userWithoutEmail = { ...mockUser, email: null } + vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({ + user: userWithoutEmail + } as Partial as UserCredential) + + await store[method]() + + expect(mockTrackAuth).toHaveBeenCalledWith( + expect.objectContaining({ email: undefined }) + ) + } + ) }) }) @@ -975,6 +1116,61 @@ describe('useAuthStore', () => { await expect(store.accessBillingPortal()).rejects.toThrow() }) + + it('throws when no auth method is available', async () => { + authStateCallback(null) + mockApiKeyGetAuthHeader.mockReturnValue(null) + + await expect(store.accessBillingPortal()).rejects.toMatchObject({ + name: 'AuthStoreError', + message: 'toastMessages.userNotAuthenticated' + }) + }) + }) + + describe('fetchBalance', () => { + it('stores the balance and update time when fetching succeeds', async () => { + await expect(store.fetchBalance()).resolves.toEqual({ balance: 0 }) + + expect(store.balance).toEqual({ balance: 0 }) + expect(store.lastBalanceUpdateTime).toBeInstanceOf(Date) + expect(store.isFetchingBalance).toBe(false) + }) + + it('throws when no auth method is available', async () => { + authStateCallback(null) + mockApiKeyGetAuthHeader.mockReturnValue(null) + + await expect(store.fetchBalance()).rejects.toMatchObject({ + name: 'AuthStoreError', + message: 'toastMessages.userNotAuthenticated' + }) + expect(store.isFetchingBalance).toBe(false) + }) + + it('returns null when the customer balance is missing', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }) + + await expect(store.fetchBalance()).resolves.toBeNull() + expect(store.balance).toBeNull() + expect(store.isFetchingBalance).toBe(false) + }) + + it('throws API errors when fetching balance fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({ message: 'Balance unavailable' }) + }) + + await expect(store.fetchBalance()).rejects.toThrow( + 'toastMessages.failedToFetchBalance' + ) + expect(store.isFetchingBalance).toBe(false) + }) }) describe('getAuthHeaderOrThrow', () => { @@ -1062,5 +1258,117 @@ describe('useAuthStore', () => { expect(error).toBeInstanceOf(AuthStoreError) expect((error as AuthStoreError).status).toBe(422) }) + + it('throws when the response has no customer id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}) + }) + + await expect(store.createCustomer()).rejects.toThrow( + 'toastMessages.failedToCreateCustomer' + ) + }) + }) + + describe('password actions', () => { + it('sends password reset emails', async () => { + vi.mocked(firebaseAuth.sendPasswordResetEmail).mockResolvedValue() + + await store.sendPasswordReset('test@example.com') + + expect(firebaseAuth.sendPasswordResetEmail).toHaveBeenCalledWith( + mockAuth, + 'test@example.com' + ) + }) + + it('updates the current user password', async () => { + vi.mocked(firebaseAuth.updatePassword).mockResolvedValue() + + await store.updatePassword('new-password') + + expect(firebaseAuth.updatePassword).toHaveBeenCalledWith( + mockUser, + 'new-password' + ) + }) + + it('throws when updating password without a user', async () => { + authStateCallback(null) + + await expect(store.updatePassword('new-password')).rejects.toMatchObject({ + name: 'AuthStoreError', + message: 'toastMessages.userNotAuthenticated' + }) + }) + }) + + describe('initiateCreditPurchase', () => { + it('creates the customer once before adding credits', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.endsWith('/customers')) { + return Promise.resolve(mockCreateCustomerResponse) + } + if (url.endsWith('/customers/credit')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ redirect_url: 'https://stripe.test' }) + }) + } + return Promise.reject(new Error('Unexpected API call')) + }) + + await store.initiateCreditPurchase({ + amount_micros: 10_000_000, + currency: 'usd' + }) + await store.initiateCreditPurchase({ + amount_micros: 10_000_000, + currency: 'usd' + }) + + const customerCalls = mockFetch.mock.calls.filter(([url]) => + String(url).endsWith('/customers') + ) + expect(customerCalls).toHaveLength(1) + }) + + it('throws when credit purchase fails', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.endsWith('/customers')) { + return Promise.resolve(mockCreateCustomerResponse) + } + if (url.endsWith('/customers/credit')) { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ message: 'Checkout unavailable' }) + }) + } + return Promise.reject(new Error('Unexpected API call')) + }) + + await expect( + store.initiateCreditPurchase({ + amount_micros: 10_000_000, + currency: 'usd' + }) + ).rejects.toThrow('toastMessages.failedToInitiateCreditPurchase') + }) + + it('throws when no auth method is available', async () => { + authStateCallback(null) + mockApiKeyGetAuthHeader.mockReturnValue(null) + + await expect( + store.initiateCreditPurchase({ + amount_micros: 10_000_000, + currency: 'usd' + }) + ).rejects.toMatchObject({ + name: 'AuthStoreError', + message: 'toastMessages.userNotAuthenticated' + }) + }) }) }) diff --git a/src/stores/bootstrapStore.test.ts b/src/stores/bootstrapStore.test.ts index 69412ef0687..2b9498d0d47 100644 --- a/src/stores/bootstrapStore.test.ts +++ b/src/stores/bootstrapStore.test.ts @@ -93,6 +93,17 @@ describe('bootstrapStore', () => { }) }) + it('does not reload authenticated stores after bootstrap already ran', async () => { + const store = useBootstrapStore() + + await store.startStoreBootstrap() + await store.startStoreBootstrap() + + await vi.waitFor(() => { + expect(store.isI18nReady).toBe(true) + }) + }) + describe('cloud mode', () => { beforeEach(() => { mockDistributionTypes.isCloud = true diff --git a/src/stores/comfyRegistryStore.test.ts b/src/stores/comfyRegistryStore.test.ts index 6cd1f73d3ac..85f8a44e72f 100644 --- a/src/stores/comfyRegistryStore.test.ts +++ b/src/stores/comfyRegistryStore.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' @@ -177,9 +178,10 @@ describe('useComfyRegistryStore', () => { it('should return null when fetching a pack with null ID', async () => { const store = useComfyRegistryStore() - vi.spyOn(store.getPackById, 'call').mockResolvedValueOnce(null) - const result = await store.getPackById.call(null!) + const result = await store.getPackById.call( + fromAny[0], unknown>(null) + ) expect(result).toBeNull() expect(mockRegistryService.getPackById).not.toHaveBeenCalled() @@ -206,6 +208,56 @@ describe('useComfyRegistryStore', () => { ) }) + it('should reuse cached packs by ID', async () => { + const store = useComfyRegistryStore() + + await store.getPacksByIds.call(['test-pack-id']) + const result = await store.getPacksByIds.call(['test-pack-id']) + + expect(result).toEqual([mockNodePack]) + expect(mockRegistryService.listAllPacks).toHaveBeenCalledTimes(1) + }) + + it('should ignore missing packs by ID', async () => { + mockRegistryService.listAllPacks.mockResolvedValueOnce({ + nodes: [fromAny({ name: 'bad' })], + total: 1, + page: 1, + limit: 10 + }) + const store = useComfyRegistryStore() + + const result = await store.getPacksByIds.call(['unknown-pack-id']) + + expect(result).toEqual([]) + }) + + it('should handle empty pack lookup responses', async () => { + mockRegistryService.listAllPacks.mockResolvedValueOnce(null) + const store = useComfyRegistryStore() + + const result = await store.getPacksByIds.call(['unknown-pack-id']) + + expect(result).toEqual([]) + }) + + it('should filter undefined pack IDs before lookup', async () => { + const store = useComfyRegistryStore() + + const result = await store.getPacksByIds.call( + fromAny([ + 'test-pack-id', + undefined + ]) + ) + + expect(result).toEqual([mockNodePack]) + expect(mockRegistryService.listAllPacks).toHaveBeenCalledWith( + { node_id: ['test-pack-id'] }, + expect.any(Object) + ) + }) + describe('inferPackFromNodeName', () => { it('should fetch a pack by comfy node name', async () => { const store = useComfyRegistryStore() diff --git a/src/stores/commandStore.test.ts b/src/stores/commandStore.test.ts index d02ea454c92..a24ee6255ff 100644 --- a/src/stores/commandStore.test.ts +++ b/src/stores/commandStore.test.ts @@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useCommandStore } from '@/stores/commandStore' +const keybindingMock = vi.hoisted(() => ({ + value: null as null | { combo: { getKeySequences: () => string[] } } +})) + vi.mock('@/composables/useErrorHandling', () => ({ useErrorHandling: () => ({ wrapWithErrorHandlingAsync: @@ -21,12 +25,13 @@ vi.mock('@/composables/useErrorHandling', () => ({ vi.mock('@/platform/keybindings/keybindingStore', () => ({ useKeybindingStore: () => ({ - getKeybindingByCommandId: () => null + getKeybindingByCommandId: () => keybindingMock.value }) })) describe('commandStore', () => { beforeEach(() => { + keybindingMock.value = null setActivePinia(createTestingPinia({ stubActions: false })) }) @@ -164,6 +169,16 @@ describe('commandStore', () => { expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip') }) + it('resolves icon as function', () => { + const store = useCommandStore() + store.registerCommand({ + id: 'icon.fn', + function: vi.fn(), + icon: () => 'pi pi-bolt' + }) + expect(store.getCommand('icon.fn')?.icon).toBe('pi pi-bolt') + }) + it('uses explicit menubarLabel over label', () => { const store = useCommandStore() store.registerCommand({ @@ -184,6 +199,16 @@ describe('commandStore', () => { }) expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label') }) + + it('resolves menubarLabel as function', () => { + const store = useCommandStore() + store.registerCommand({ + id: 'mbl.fn', + function: vi.fn(), + menubarLabel: () => 'Dynamic menu' + }) + expect(store.getCommand('mbl.fn')?.menubarLabel).toBe('Dynamic menu') + }) }) describe('formatKeySequence', () => { @@ -193,5 +218,17 @@ describe('commandStore', () => { const cmd = store.getCommand('no.kb')! expect(store.formatKeySequence(cmd)).toBe('') }) + + it('formats keybinding sequences', () => { + const store = useCommandStore() + keybindingMock.value = { + combo: { getKeySequences: () => ['Control+A', 'Shift+B'] } + } + store.registerCommand({ id: 'with.kb', function: vi.fn() }) + + const cmd = store.getCommand('with.kb')! + + expect(store.formatKeySequence(cmd)).toBe('Ctrl+A + Shift+B') + }) }) }) diff --git a/src/stores/dialogStore.test.ts b/src/stores/dialogStore.test.ts index e28050690a2..6f3e22022ac 100644 --- a/src/stores/dialogStore.test.ts +++ b/src/stores/dialogStore.test.ts @@ -1,6 +1,6 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { useDialogStore } from '@/stores/dialogStore' @@ -141,6 +141,110 @@ describe('dialogStore', () => { }) describe('basic dialog operations', () => { + it('generates a key when none is provided', () => { + const store = useDialogStore() + + const dialog = store.showDialog({ component: MockComponent }) + + expect(dialog.key).toMatch(/^dialog-/) + expect(store.isDialogOpen(dialog.key)).toBe(true) + }) + + it('evicts the first stack entry when the stack is full', () => { + const store = useDialogStore() + + for (let i = 0; i < 11; i++) { + store.showDialog({ + key: `dialog-${i}`, + component: MockComponent, + priority: i + }) + } + + expect(store.dialogStack).toHaveLength(10) + expect(store.isDialogOpen('dialog-9')).toBe(false) + }) + + it('stores optional header and footer components and props', () => { + const store = useDialogStore() + + const dialog = store.showDialog({ + key: 'with-slots', + component: MockComponent, + headerComponent: MockComponent, + footerComponent: MockComponent, + headerProps: { title: 'Header' }, + footerProps: { action: 'Save' } + }) + + expect(dialog.headerComponent).toBeDefined() + expect(dialog.footerComponent).toBeDefined() + expect(dialog.headerProps).toEqual({ title: 'Header' }) + expect(dialog.footerProps).toEqual({ action: 'Save' }) + }) + + it('runs dialog lifecycle handlers', () => { + const store = useDialogStore() + const onClose = vi.fn() + const dialog = store.showDialog({ + key: 'lifecycle', + component: MockComponent, + dialogComponentProps: { onClose } + }) + const props = + dialog.dialogComponentProps as typeof dialog.dialogComponentProps & { + onAfterHide: () => void + onMaximize: () => void + onUnmaximize: () => void + pt: { root: { onMousedown: () => void } } + } + + props.onMaximize() + expect(dialog.dialogComponentProps.maximized).toBe(true) + + props.onUnmaximize() + expect(dialog.dialogComponentProps.maximized).toBe(false) + + props.pt.root.onMousedown() + expect(store.activeKey).toBe('lifecycle') + + props.onAfterHide() + expect(onClose).toHaveBeenCalledOnce() + expect(store.isDialogOpen('lifecycle')).toBe(false) + }) + + it('does nothing when rising or closing a missing dialog', () => { + const store = useDialogStore() + + store.riseDialog({ key: 'missing' }) + store.closeDialog({ key: 'missing' }) + + expect(store.dialogStack).toEqual([]) + expect(store.activeKey).toBeNull() + }) + + it('closes the active dialog when no key is provided', () => { + const store = useDialogStore() + + store.showDialog({ key: 'active', component: MockComponent }) + store.closeDialog() + + expect(store.isDialogOpen('active')).toBe(false) + expect(store.activeKey).toBeNull() + }) + + it('disables escape closing for a non-closable active dialog', () => { + const store = useDialogStore() + + const dialog = store.showDialog({ + key: 'locked', + component: MockComponent, + dialogComponentProps: { closable: false } + }) + + expect(dialog.dialogComponentProps.closeOnEscape).toBe(false) + }) + it('should show and close dialogs', () => { const store = useDialogStore() @@ -208,6 +312,86 @@ describe('dialogStore', () => { false ) }) + + it('updates only content props when dialog component props are omitted', () => { + const store = useDialogStore() + + store.showDialog({ + key: 'content-only', + component: MockContentPropsComponent, + props: { openingAction: null } + }) + + expect( + store.updateDialog({ + key: 'content-only', + contentProps: { openingAction: 'open' } + }) + ).toBe(true) + expect(store.dialogStack[0].contentProps.openingAction).toBe('open') + }) + + it('updates only dialog component props when content props are omitted', () => { + const store = useDialogStore() + + store.showDialog({ + key: 'dialog-props-only', + component: MockContentPropsComponent, + dialogComponentProps: { dismissableMask: true } + }) + + expect( + store.updateDialog({ + key: 'dialog-props-only', + dialogComponentProps: { dismissableMask: false } + }) + ).toBe(true) + expect(store.dialogStack[0].dialogComponentProps.dismissableMask).toBe( + false + ) + }) + + it('returns false when updating a missing dialog', () => { + const store = useDialogStore() + + expect( + store.updateDialog({ + key: 'missing', + contentProps: { openingAction: 'open' } + }) + ).toBe(false) + }) + + it('creates and reuses extension dialogs with extension-prefixed keys', () => { + const store = useDialogStore() + + const first = store.showExtensionDialog({ + key: 'external', + component: MockComponent + }) + const second = store.showExtensionDialog({ + key: 'extension-external', + component: MockComponent + }) + + expect(first?.key).toBe('extension-external') + expect(second?.key).toBe(first?.key) + expect(store.dialogStack).toHaveLength(1) + }) + + it('rejects extension dialogs without keys', () => { + const store = useDialogStore() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const dialog = store.showExtensionDialog({ + key: '', + component: MockComponent + }) + + expect(dialog).toBeUndefined() + expect(error).toHaveBeenCalledWith('Extension dialog key is required') + error.mockRestore() + }) }) describe('ESC key behavior with multiple dialogs', () => { diff --git a/src/stores/domWidgetStore.test.ts b/src/stores/domWidgetStore.test.ts index 100b8fe4005..5a7daccd0be 100644 --- a/src/stores/domWidgetStore.test.ts +++ b/src/stores/domWidgetStore.test.ts @@ -112,6 +112,36 @@ describe('domWidgetStore', () => { store.activateWidget('non-existent') }).not.toThrow() }) + + it('should ignore deactivating non-existent widgets', () => { + store.deactivateWidget('non-existent') + + expect(store.widgetStates.size).toBe(0) + }) + + it('should replace registered widgets', () => { + const widget = createMockDOMWidget('widget-1') + const replacement = { + ...createMockDOMWidget('widget-1'), + value: 'replacement' + } + store.registerWidget(widget) + store.deactivateWidget('widget-1') + + store.setWidget(replacement) + + const state = store.widgetStates.get('widget-1') + expect(state?.widget.value).toBe('replacement') + expect(state?.active).toBe(true) + }) + + it('should ignore missing widgets when replacing', () => { + const widget = createMockDOMWidget('widget-1') + + store.setWidget(widget) + + expect(store.widgetStates.size).toBe(0) + }) }) describe('computed states', () => { diff --git a/src/stores/electronDownloadStore.nonDesktop.test.ts b/src/stores/electronDownloadStore.nonDesktop.test.ts new file mode 100644 index 00000000000..67478f3bbf0 --- /dev/null +++ b/src/stores/electronDownloadStore.nonDesktop.test.ts @@ -0,0 +1,26 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useElectronDownloadStore } from '@/stores/electronDownloadStore' + +const electronAPI = vi.hoisted(() => vi.fn()) + +vi.mock('@/platform/distribution/types', () => ({ isDesktop: false })) +vi.mock('@/utils/envUtil', () => ({ electronAPI })) + +describe('electronDownloadStore outside desktop', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + electronAPI.mockClear() + }) + + it('skips the Electron bridge when not running on desktop', async () => { + const store = useElectronDownloadStore() + + await store.initialize() + + expect(electronAPI).not.toHaveBeenCalled() + expect(store.downloads).toEqual([]) + }) +}) diff --git a/src/stores/electronDownloadStore.test.ts b/src/stores/electronDownloadStore.test.ts new file mode 100644 index 00000000000..258dd735c61 --- /dev/null +++ b/src/stores/electronDownloadStore.test.ts @@ -0,0 +1,106 @@ +import { DownloadStatus } from '@comfyorg/comfyui-electron-types' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useElectronDownloadStore } from '@/stores/electronDownloadStore' + +const downloadManagerMock = vi.hoisted(() => ({ + cancelDownload: vi.fn(), + getAllDownloads: vi.fn(), + onDownloadProgress: vi.fn(), + pauseDownload: vi.fn(), + resumeDownload: vi.fn(), + startDownload: vi.fn() +})) + +vi.mock('@/platform/distribution/types', () => ({ + isDesktop: true +})) + +vi.mock('@/utils/envUtil', () => ({ + electronAPI: () => ({ + DownloadManager: downloadManagerMock + }) +})) + +const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)) + +describe('electronDownloadStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + Object.values(downloadManagerMock).forEach((mock) => mock.mockReset()) + downloadManagerMock.getAllDownloads.mockResolvedValue([ + { + filename: 'done.bin', + status: DownloadStatus.COMPLETED, + url: 'https://example.com/done.bin' + } + ]) + }) + + it('loads existing downloads and applies progress updates by URL', async () => { + let progressCallback: + | Parameters[0] + | undefined + downloadManagerMock.onDownloadProgress.mockImplementation((callback) => { + progressCallback = callback + }) + const store = useElectronDownloadStore() + + await flushPromises() + progressCallback?.({ + filename: 'model.bin', + progress: 25, + savePath: '/tmp/model.bin', + status: DownloadStatus.IN_PROGRESS, + url: 'https://example.com/model.bin' + }) + progressCallback?.({ + filename: 'model.bin', + progress: 50, + savePath: '/tmp/model.bin', + status: DownloadStatus.IN_PROGRESS, + url: 'https://example.com/model.bin' + }) + + expect(store.findByUrl('https://example.com/done.bin')?.status).toBe( + DownloadStatus.COMPLETED + ) + expect(store.findByUrl('https://example.com/model.bin')).toMatchObject({ + filename: 'model.bin', + progress: 50, + status: DownloadStatus.IN_PROGRESS + }) + expect(store.inProgressDownloads).toHaveLength(1) + }) + + it('delegates download controls to the Electron bridge', async () => { + const store = useElectronDownloadStore() + + await store.start({ + filename: 'model.bin', + savePath: '/tmp/model.bin', + url: 'https://example.com/model.bin' + }) + await store.pause('https://example.com/model.bin') + await store.resume('https://example.com/model.bin') + await store.cancel('https://example.com/model.bin') + + expect(downloadManagerMock.startDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin', + '/tmp/model.bin', + 'model.bin' + ) + expect(downloadManagerMock.pauseDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin' + ) + expect(downloadManagerMock.resumeDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin' + ) + expect(downloadManagerMock.cancelDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin' + ) + }) +}) diff --git a/src/stores/electronDownloadStore.ts b/src/stores/electronDownloadStore.ts index fbf29655340..c36f74f878e 100644 --- a/src/stores/electronDownloadStore.ts +++ b/src/stores/electronDownloadStore.ts @@ -33,18 +33,15 @@ export const useElectronDownloadStore = defineStore('downloads', () => { } DownloadManager.onDownloadProgress((data) => { - if (!findByUrl(data.url)) { - downloads.value.push(data) - } - const download = findByUrl(data.url) - - if (download) { - download.progress = data.progress - download.status = data.status - download.filename = data.filename - download.savePath = data.savePath + if (!download) { + downloads.value.push(data) + return } + download.progress = data.progress + download.status = data.status + download.filename = data.filename + download.savePath = data.savePath }) } diff --git a/src/stores/executionError.test.ts b/src/stores/executionError.test.ts new file mode 100644 index 00000000000..6661183edd5 --- /dev/null +++ b/src/stores/executionError.test.ts @@ -0,0 +1,197 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' +import { useExecutionStore } from '@/stores/executionStore' + +const { + handlers, + openSet, + errorStore, + dist, + resolvePrecondition, + classifyCloud +} = vi.hoisted(() => ({ + handlers: {} as Record void>, + openSet: new Set(), + errorStore: { + clearExecutionStartErrors: () => {}, + clearPromptError: () => {} + } as Record, + dist: { isCloud: false }, + resolvePrecondition: vi.fn(), + classifyCloud: vi.fn() +})) + +vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => { + handlers[name] = fn + }, + removeEventListener: () => {} + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + isOpen: (workflow: unknown) => openSet.has(workflow), + openWorkflows: [], + nodeLocatorIdToNodeExecutionId: () => null + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: undefined }) +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => errorStore +})) + +vi.mock('@/composables/useAppMode', () => ({ + useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) }) +})) + +vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined })) + +vi.mock('@/utils/appMode', () => ({ + getWorkflowMode: () => 'workflow', + isAppModeValue: () => false +})) + +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return dist.isCloud + } +})) + +vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({ + resolveAccountPrecondition: resolvePrecondition +})) + +vi.mock('@/utils/executionErrorUtil', () => ({ + classifyCloudValidationError: classifyCloud +})) + +function workflow(path: string): ComfyWorkflow { + return { path } as unknown as ComfyWorkflow +} + +function promptOutput(): ComfyApiWorkflow { + return {} +} + +function setup() { + const store = useExecutionStore() + store.bindExecutionEvents() + return store +} + +function fireError(detail: Record) { + handlers['execution_error']?.({ detail }) +} + +beforeEach(() => { + setActivePinia(createPinia()) + for (const key of Object.keys(handlers)) delete handlers[key] + openSet.clear() + dist.isCloud = false + resolvePrecondition.mockReturnValue(null) + classifyCloud.mockReturnValue(null) + for (const key of ['lastExecutionError', 'lastPromptError', 'lastNodeErrors']) + delete errorStore[key] +}) + +describe('executionStore error handling', () => { + it('marks an open workflow failed and records the raw execution error', () => { + const store = setup() + const wf = workflow('a.json') + openSet.add(wf) + store.storeJob({ + nodes: [], + id: 'job-1', + promptOutput: promptOutput(), + workflow: wf + }) + + const detail = { + prompt_id: 'job-1', + node_id: '5', + exception_message: 'boom' + } + fireError(detail) + + expect(store.getWorkflowStatus(wf)).toBe('failed') + expect(errorStore.lastExecutionError).toBe(detail) + }) + + it('routes account-precondition errors away from the failed badge', () => { + resolvePrecondition.mockReturnValue({ type: 'credits' }) + const store = setup() + const wf = workflow('b.json') + openSet.add(wf) + store.storeJob({ + nodes: [], + id: 'job-2', + promptOutput: promptOutput(), + workflow: wf + }) + + fireError({ + prompt_id: 'job-2', + node_id: '5', + exception_type: 'AccountError' + }) + + expect(resolvePrecondition).toHaveBeenCalledWith({ + exceptionType: 'AccountError', + exceptionMessage: '' + }) + expect(store.getWorkflowStatus(wf)).toBeUndefined() + expect(errorStore.lastExecutionError).toBeUndefined() + expect(errorStore.lastPromptError).toBeUndefined() + }) + + it('records a node-less service-level error as a prompt error', () => { + setup() + + fireError({ + prompt_id: 'job-3', + exception_type: 'StagnationError', + exception_message: 'stuck', + traceback: ['line1', 'line2'] + }) + + expect(errorStore.lastPromptError).toEqual({ + type: 'StagnationError', + message: 'StagnationError: stuck', + details: 'line1\nline2' + }) + }) + + it('records classified cloud validation node errors without a failed badge', () => { + dist.isCloud = true + classifyCloud.mockReturnValue({ + kind: 'nodeErrors', + nodeErrors: { '5': { errors: [] } } + }) + const store = setup() + const wf = workflow('c.json') + openSet.add(wf) + store.storeJob({ + nodes: [], + id: 'job-4', + promptOutput: promptOutput(), + workflow: wf + }) + + fireError({ prompt_id: 'job-4', exception_message: '{"nodeErrors":{}}' }) + + expect(store.getWorkflowStatus(wf)).toBeUndefined() + expect(errorStore.lastNodeErrors).toEqual({ '5': { errors: [] } }) + }) +}) diff --git a/src/stores/executionErrorStore.test.ts b/src/stores/executionErrorStore.test.ts index dbc0a012420..48c841d6b56 100644 --- a/src/stores/executionErrorStore.test.ts +++ b/src/stores/executionErrorStore.test.ts @@ -2,8 +2,10 @@ import { fromAny } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { MissingNodeType } from '@/types/comfy' import { createNodeExecutionId } from '@/types/nodeIdentification' +import type { NodeLocatorId } from '@/types/nodeIdentification' // Mock dependencies vi.mock('@/i18n', () => ({ @@ -15,6 +17,53 @@ vi.mock('@/platform/distribution/types', () => ({ })) const mockShowErrorsTab = vi.hoisted(() => ({ value: false })) +const { + mockApp, + mockCanvasStore, + mockExecutionIdToNodeLocatorId, + mockGetExecutionIdByNode, + mockGetNodeByExecutionId, + mockWorkflowStore +} = vi.hoisted(() => ({ + mockApp: { + isGraphReady: true, + rootGraph: {} + }, + mockCanvasStore: { + currentGraph: undefined as object | undefined + }, + mockExecutionIdToNodeLocatorId: vi.fn( + (_rootGraph: unknown, id: string) => id as NodeLocatorId + ), + mockGetExecutionIdByNode: vi.fn(), + mockGetNodeByExecutionId: vi.fn(), + mockWorkflowStore: { + nodeLocatorIdToNodeId: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ app: mockApp })) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => mockCanvasStore +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => mockWorkflowStore +})) + +vi.mock('@/utils/graphTraversalUtil', () => ({ + executionIdToNodeLocatorId: ( + ...args: Parameters + ) => mockExecutionIdToNodeLocatorId(...args), + forEachNode: vi.fn(), + getExecutionIdByNode: ( + ...args: Parameters + ) => mockGetExecutionIdByNode(...args), + getNodeByExecutionId: ( + ...args: Parameters + ) => mockGetNodeByExecutionId(...args) +})) vi.mock('@/stores/settingStore', () => ({ useSettingStore: vi.fn(() => ({ @@ -39,6 +88,22 @@ import { useExecutionErrorStore } from './executionErrorStore' import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore' import { toNodeId } from '@/types/nodeId' +beforeEach(() => { + mockShowErrorsTab.value = false + mockApp.isGraphReady = true + mockCanvasStore.currentGraph = undefined + mockExecutionIdToNodeLocatorId.mockImplementation( + (_rootGraph: unknown, id: string) => id as NodeLocatorId + ) + mockGetExecutionIdByNode.mockReset() + mockGetNodeByExecutionId.mockReset() + mockWorkflowStore.nodeLocatorIdToNodeId.mockReset() + mockWorkflowStore.nodeLocatorIdToNodeId.mockImplementation( + (locator: NodeLocatorId) => + toNodeId(String(locator).split(':').at(-1) ?? locator) + ) +}) + describe('executionErrorStore — node error operations', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -144,6 +209,31 @@ describe('executionErrorStore — node error operations', () => { expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1) }) + it('does nothing when the requested slot has no errors', () => { + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '123': { + errors: [ + { + type: 'value_bigger_than_max', + message: 'Max exceeded', + details: '', + extra_info: { input_name: 'otherSlot' } + } + ], + dependent_outputs: [], + class_type: 'TestNode' + } + } + + store.clearSimpleNodeErrors( + createNodeExecutionId([toNodeId(123)]), + 'testSlot' + ) + + expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1) + }) + it('preserves complex errors when slot has both simple and complex errors', () => { const store = useExecutionErrorStore() store.lastNodeErrors = { @@ -388,6 +478,358 @@ describe('executionErrorStore — node error operations', () => { expect(store.lastNodeErrors).not.toBeNull() expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1) }) + + it('keeps numeric range errors when no range options prove them valid', () => { + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '123': { + errors: [ + { + type: 'value_bigger_than_max', + message: '...', + details: '', + extra_info: { input_name: 'testWidget' } + } + ], + dependent_outputs: [], + class_type: 'TestNode' + } + } + + store.clearWidgetRelatedErrors( + createNodeExecutionId([toNodeId(123)]), + 'testWidget', + 'testWidget', + 15 + ) + + expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1) + }) + + it('clears simple widget errors when the numeric value has no node error entry', () => { + const store = useExecutionErrorStore() + store.lastNodeErrors = { + '999': { + errors: [ + { + type: 'value_bigger_than_max', + message: '...', + details: '', + extra_info: { input_name: 'testWidget' } + } + ], + dependent_outputs: [], + class_type: 'TestNode' + } + } + + store.clearWidgetRelatedErrors( + createNodeExecutionId([toNodeId(123)]), + 'testWidget', + 'testWidget', + 15, + { max: 10 } + ) + + expect(store.lastNodeErrors?.['999'].errors).toHaveLength(1) + }) + }) + + describe('startup clearing', () => { + it('clears execution-start errors and closes the overlay when node errors are empty', () => { + const store = useExecutionErrorStore() + store.lastExecutionError = fromAny({ node_id: '1' }) + store.lastPromptError = fromAny({ message: 'prompt failed' }) + store.lastNodeErrors = {} + store.showErrorOverlay() + + store.clearExecutionStartErrors() + + expect(store.lastExecutionError).toBeNull() + expect(store.lastPromptError).toBeNull() + expect(store.isErrorOverlayOpen).toBe(false) + }) + + it('keeps the overlay open when node errors remain after execution start', () => { + const store = useExecutionErrorStore() + store.lastExecutionError = fromAny({ node_id: '1' }) + store.lastPromptError = fromAny({ message: 'prompt failed' }) + store.lastNodeErrors = { + '1': { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'x' } + } + ], + dependent_outputs: [], + class_type: 'Test' + } + } + store.showErrorOverlay() + + store.clearExecutionStartErrors() + + expect(store.isErrorOverlayOpen).toBe(true) + }) + }) +}) + +describe('executionErrorStore derived graph state', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('derives execution error node ids through locator mapping', () => { + const store = useExecutionErrorStore() + mockExecutionIdToNodeLocatorId.mockReturnValue( + fromAny('graph:7') + ) + store.lastExecutionError = fromAny({ node_id: '7' }) + + expect(store.lastExecutionErrorNodeId).toBe(toNodeId(7)) + }) + + it('returns null when there is no execution error locator', () => { + const store = useExecutionErrorStore() + store.lastExecutionError = fromAny({ node_id: '7' }) + mockExecutionIdToNodeLocatorId.mockReturnValue( + fromAny(undefined) + ) + + expect(store.lastExecutionErrorNodeId).toBeNull() + }) + + it('returns null when there is no execution error', () => { + const store = useExecutionErrorStore() + + expect(store.lastExecutionErrorNodeId).toBeNull() + }) + + it('combines prompt, node, execution, and missing-node error counts', () => { + const store = useExecutionErrorStore() + const missingNodesStore = useMissingNodesErrorStore() + store.lastPromptError = fromAny({ message: 'prompt failed' }) + store.lastExecutionError = fromAny({ node_id: null }) + store.lastNodeErrors = { + '1': { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'x' } + }, + { + type: 'value_bigger_than_max', + message: 'Too large', + details: '', + extra_info: { input_name: 'y' } + } + ], + dependent_outputs: [], + class_type: 'Test' + } + } + missingNodesStore.setMissingNodeTypes( + fromAny([{ type: 'MissingNode', hint: '' }]) + ) + + expect(store.hasPromptError).toBe(true) + expect(store.hasNodeError).toBe(true) + expect(store.hasExecutionError).toBe(true) + expect(store.hasAnyError).toBe(true) + expect(store.allErrorExecutionIds).toEqual(['1']) + expect(store.totalErrorCount).toBe(5) + }) + + it('reports empty derived state when there are no errors', () => { + const store = useExecutionErrorStore() + + expect(store.hasNodeError).toBe(false) + expect(store.allErrorExecutionIds).toEqual([]) + expect(store.totalErrorCount).toBe(0) + }) + + it('includes defined execution node ids in the error id list', () => { + const store = useExecutionErrorStore() + store.lastExecutionError = fromAny({ node_id: '2' }) + + expect(store.allErrorExecutionIds).toEqual(['2']) + }) + + it('excludes undefined execution node ids from the error id list', () => { + const store = useExecutionErrorStore() + store.lastExecutionError = fromAny({ node_id: undefined }) + + expect(store.allErrorExecutionIds).toEqual([]) + }) + + it('collects active graph node ids for validation and execution errors', () => { + const store = useExecutionErrorStore() + const activeGraph = {} + mockCanvasStore.currentGraph = activeGraph + mockGetNodeByExecutionId.mockImplementation((_rootGraph, id: string) => ({ + id: toNodeId(id), + graph: activeGraph + })) + store.lastNodeErrors = { + '1': { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'x' } + } + ], + dependent_outputs: [], + class_type: 'Test' + } + } + store.lastExecutionError = fromAny({ node_id: '2' }) + + expect([...store.activeGraphErrorNodeIds].sort()).toEqual(['1', '2']) + }) + + it('falls back to the root graph when there is no current canvas graph', () => { + const store = useExecutionErrorStore() + mockCanvasStore.currentGraph = undefined + mockGetNodeByExecutionId.mockReturnValue({ + id: toNodeId(1), + graph: mockApp.rootGraph + }) + store.lastNodeErrors = { + '1': { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'x' } + } + ], + dependent_outputs: [], + class_type: 'Test' + } + } + + expect([...store.activeGraphErrorNodeIds]).toEqual(['1']) + }) + + it('ignores graph errors outside the active graph', () => { + const store = useExecutionErrorStore() + const activeGraph = {} + mockCanvasStore.currentGraph = activeGraph + mockGetNodeByExecutionId.mockReturnValue({ + id: toNodeId(1), + graph: {} + }) + store.lastNodeErrors = { + '1': { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'x' } + } + ], + dependent_outputs: [], + class_type: 'Test' + } + } + store.lastExecutionError = fromAny({ node_id: '1' }) + + expect(store.activeGraphErrorNodeIds.size).toBe(0) + }) + + it('returns no active graph node ids before the graph is ready', () => { + const store = useExecutionErrorStore() + mockApp.isGraphReady = false + store.lastExecutionError = fromAny({ node_id: '2' }) + + expect(store.activeGraphErrorNodeIds.size).toBe(0) + }) + + it('maps node errors by locator and checks slots', () => { + const store = useExecutionErrorStore() + const nodeError = { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'x' } + } + ], + dependent_outputs: [], + class_type: 'Test' + } + mockExecutionIdToNodeLocatorId.mockImplementation((_rootGraph, id) => + id === 'missing' + ? fromAny(undefined) + : fromAny(`locator:${id}`) + ) + store.lastNodeErrors = { + '1': nodeError, + missing: nodeError + } + + const locator = fromAny('locator:1') + expect(store.getNodeErrors(locator)).toEqual(nodeError) + expect(store.slotHasError(locator, 'x')).toBe(true) + expect(store.slotHasError(locator, 'y')).toBe(false) + expect( + store.getNodeErrors(fromAny('locator:missing')) + ).toBeUndefined() + }) + + it('returns no slot error when there are no node errors', () => { + const store = useExecutionErrorStore() + + expect( + store.slotHasError(fromAny('locator:1'), 'x') + ).toBe(false) + }) + + it('detects container nodes with internal errors', () => { + const store = useExecutionErrorStore() + const node = fromAny({}) + mockGetExecutionIdByNode.mockReturnValueOnce(undefined) + + expect(store.isContainerWithInternalError(node)).toBe(false) + + store.lastNodeErrors = { + '1:2': { + errors: [ + { + type: 'required_input_missing', + message: 'Missing', + details: '', + extra_info: { input_name: 'x' } + } + ], + dependent_outputs: [], + class_type: 'Test' + } + } + mockGetExecutionIdByNode.mockReturnValue( + createNodeExecutionId([toNodeId(1)]) + ) + + expect(store.isContainerWithInternalError(node)).toBe(true) + }) + + it('does not report container errors before the graph is ready', () => { + const store = useExecutionErrorStore() + mockApp.isGraphReady = false + + expect( + store.isContainerWithInternalError(fromAny({})) + ).toBe(false) }) }) @@ -457,6 +899,23 @@ describe('surfaceMissingModels — silent option', () => { expect(store.isErrorOverlayOpen).toBe(false) }) + + it('does NOT open error overlay when the setting is disabled', () => { + const store = useExecutionErrorStore() + mockShowErrorsTab.value = false + store.surfaceMissingModels([ + fromAny({ + name: 'model.safetensors', + nodeId: toNodeId('1'), + nodeType: 'Loader', + widgetName: 'ckpt', + isMissing: true, + isAssetSupported: false + }) + ]) + + expect(store.isErrorOverlayOpen).toBe(false) + }) }) describe('surfaceMissingMedia — silent option', () => { @@ -525,6 +984,23 @@ describe('surfaceMissingMedia — silent option', () => { expect(store.isErrorOverlayOpen).toBe(false) }) + + it('does NOT open error overlay when the setting is disabled', () => { + const store = useExecutionErrorStore() + mockShowErrorsTab.value = false + store.surfaceMissingMedia([ + fromAny({ + name: 'photo.png', + nodeId: toNodeId('1'), + nodeType: 'LoadImage', + widgetName: 'image', + mediaType: 'image', + isMissing: true + }) + ]) + + expect(store.isErrorOverlayOpen).toBe(false) + }) }) describe('clearAllErrors', () => { diff --git a/src/stores/executionInterrupt.test.ts b/src/stores/executionInterrupt.test.ts new file mode 100644 index 00000000000..074a876feeb --- /dev/null +++ b/src/stores/executionInterrupt.test.ts @@ -0,0 +1,120 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' +import { useExecutionStore } from '@/stores/executionStore' + +const { handlers, openSet } = vi.hoisted(() => ({ + handlers: {} as Record void>, + openSet: new Set() +})) + +vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => { + handlers[name] = fn + }, + removeEventListener: () => {} + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + isOpen: (workflow: unknown) => openSet.has(workflow), + openWorkflows: [], + nodeLocatorIdToNodeExecutionId: () => null + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: undefined }) +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => ({ + clearExecutionStartErrors: () => {}, + clearPromptError: () => {} + }) +})) + +vi.mock('@/composables/useAppMode', () => ({ + useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) }) +})) + +vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined })) + +vi.mock('@/utils/appMode', () => ({ + getWorkflowMode: () => 'workflow', + isAppModeValue: () => false +})) + +function workflow(path: string): ComfyWorkflow { + return { path } as unknown as ComfyWorkflow +} + +function setup() { + const store = useExecutionStore() + store.bindExecutionEvents() + return store +} + +function promptOutput(): ComfyApiWorkflow { + return {} +} + +function startJob( + store: ReturnType, + id: string, + wf: ComfyWorkflow, + nodes: string[] = [] +) { + openSet.add(wf) + store.storeJob({ nodes, id, promptOutput: promptOutput(), workflow: wf }) + handlers['execution_start']?.({ detail: { prompt_id: id } }) +} + +beforeEach(() => { + setActivePinia(createPinia()) + for (const key of Object.keys(handlers)) delete handlers[key] + openSet.clear() +}) + +describe('executionStore interrupt and cached', () => { + it('drops the workflow badge and goes idle on interruption', () => { + const store = setup() + const wf = workflow('a.json') + startJob(store, 'job-1', wf) + expect(store.getWorkflowStatus(wf)).toBe('running') + + handlers['execution_interrupted']?.({ detail: { prompt_id: 'job-1' } }) + + expect(store.getWorkflowStatus(wf)).toBeUndefined() + expect(store.isIdle).toBe(true) + }) + + it('ends the active job when executing resolves to null', () => { + const store = setup() + startJob(store, 'job-2', workflow('b.json')) + expect(store.isIdle).toBe(false) + + handlers['executing']?.({ detail: null }) + + expect(store.isIdle).toBe(true) + }) + + it('marks cached nodes as executed', () => { + const store = setup() + startJob(store, 'job-3', workflow('c.json'), ['a', 'b', 'c']) + expect(store.nodesExecuted).toBe(0) + + handlers['execution_cached']?.({ + detail: { prompt_id: 'job-3', nodes: ['a', 'b'] } + }) + + expect(store.nodesExecuted).toBe(2) + }) +}) diff --git a/src/stores/executionLifecycle.test.ts b/src/stores/executionLifecycle.test.ts new file mode 100644 index 00000000000..b663f0994ab --- /dev/null +++ b/src/stores/executionLifecycle.test.ts @@ -0,0 +1,119 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' +import { useExecutionStore } from '@/stores/executionStore' + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void> +})) + +vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => { + handlers[name] = fn + }, + removeEventListener: () => {} + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + isOpen: () => false, + openWorkflows: [], + nodeLocatorIdToNodeExecutionId: () => null + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: undefined }) +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => ({ + clearExecutionStartErrors: () => {}, + clearPromptError: () => {} + }) +})) + +vi.mock('@/composables/useAppMode', () => ({ + useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) }) +})) + +vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined })) + +vi.mock('@/utils/appMode', () => ({ + getWorkflowMode: () => 'workflow', + isAppModeValue: () => false +})) + +function setup() { + const store = useExecutionStore() + store.bindExecutionEvents() + return store +} + +function promptOutput(): ComfyApiWorkflow { + return {} +} + +function startJob( + store: ReturnType, + id: string, + nodes: string[] +) { + store.storeJob({ + nodes, + id, + promptOutput: promptOutput(), + workflow: { path: `${id}.json` } as unknown as ComfyWorkflow + }) + handlers['execution_start']?.({ detail: { prompt_id: id } }) +} + +beforeEach(() => { + setActivePinia(createPinia()) + for (const key of Object.keys(handlers)) delete handlers[key] +}) + +describe('executionStore execution lifecycle', () => { + it('reports zero progress while idle', () => { + const store = setup() + expect(store.totalNodesToExecute).toBe(0) + expect(store.nodesExecuted).toBe(0) + expect(store.executionProgress).toBe(0) + }) + + it('counts the queued nodes once a job starts', () => { + const store = setup() + startJob(store, 'job-1', ['a', 'b', 'c']) + + expect(store.totalNodesToExecute).toBe(3) + expect(store.nodesExecuted).toBe(0) + expect(store.executionProgress).toBe(0) + }) + + it('advances progress as executed events arrive', () => { + const store = setup() + startJob(store, 'job-1', ['a', 'b', 'c']) + + handlers['executed']?.({ detail: { node: 'a' } }) + expect(store.nodesExecuted).toBe(1) + expect(store.executionProgress).toBeCloseTo(1 / 3) + + handlers['executed']?.({ detail: { node: 'b' } }) + handlers['executed']?.({ detail: { node: 'c' } }) + expect(store.nodesExecuted).toBe(3) + expect(store.executionProgress).toBe(1) + }) + + it('ignores executed events when there is no active job', () => { + const store = setup() + handlers['executed']?.({ detail: { node: 'a' } }) + expect(store.nodesExecuted).toBe(0) + }) +}) diff --git a/src/stores/executionNodeProgress.test.ts b/src/stores/executionNodeProgress.test.ts new file mode 100644 index 00000000000..fc10114d982 --- /dev/null +++ b/src/stores/executionNodeProgress.test.ts @@ -0,0 +1,128 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { NodeProgressState } from '@/schemas/apiSchema' +import { useExecutionStore } from '@/stores/executionStore' + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void> +})) + +vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => { + handlers[name] = fn + }, + removeEventListener: () => {} + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + isOpen: () => false, + openWorkflows: [], + nodeLocatorIdToNodeExecutionId: () => null + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: undefined }) +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => ({ + clearExecutionStartErrors: () => {}, + clearPromptError: () => {} + }) +})) + +vi.mock('@/stores/nodeOutputStore', () => ({ + useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} }) +})) + +vi.mock('@/composables/useAppMode', () => ({ + useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) }) +})) + +vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined })) + +vi.mock('@/utils/appMode', () => ({ + getWorkflowMode: () => 'workflow', + isAppModeValue: () => false +})) + +function progressState( + jobId: string, + nodes: Record> +) { + handlers['progress_state']?.({ detail: { prompt_id: jobId, nodes } }) +} + +function setup() { + const store = useExecutionStore() + store.bindExecutionEvents() + return store +} + +beforeEach(() => { + setActivePinia(createPinia()) + for (const key of Object.keys(handlers)) delete handlers[key] +}) + +describe('executionStore node progress', () => { + it('is idle until an execution starts', () => { + const store = setup() + expect(store.isIdle).toBe(true) + + handlers['execution_start']?.({ detail: { prompt_id: 'job-1' } }) + expect(store.isIdle).toBe(false) + }) + + it('derives the running node ids from a progress_state event', () => { + const store = setup() + + progressState('job-1', { + n1: { state: 'running', value: 1, max: 4 }, + n2: { state: 'finished' }, + n3: { state: 'pending' } + }) + + expect(store.executingNodeIds).toEqual(['n1']) + expect(store.executingNodeId).toBe('n1') + }) + + it('exposes fractional progress for the executing node', () => { + const store = setup() + + progressState('job-1', { + n1: { state: 'running', value: 1, max: 4 } + }) + + expect(store.executingNodeProgress).toBe(0.25) + }) + + it('reports no executing node when none are running', () => { + const store = setup() + + progressState('job-1', { + n1: { state: 'finished' }, + n2: { state: 'pending' } + }) + + expect(store.executingNodeIds).toEqual([]) + expect(store.executingNodeId).toBeNull() + }) + + it('replaces progress state on each progress_state event', () => { + const store = setup() + + progressState('job-1', { n1: { state: 'running', value: 1, max: 4 } }) + expect(store.executingNodeId).toBe('n1') + + progressState('job-1', { n2: { state: 'running', value: 2, max: 2 } }) + expect(store.executingNodeIds).toEqual(['n2']) + }) +}) diff --git a/src/stores/executionRunningState.test.ts b/src/stores/executionRunningState.test.ts new file mode 100644 index 00000000000..78e3d2f99b1 --- /dev/null +++ b/src/stores/executionRunningState.test.ts @@ -0,0 +1,173 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' +import { useExecutionStore } from '@/stores/executionStore' +import type { classifyCloudValidationError } from '@/utils/executionErrorUtil' + +type CloudValidationResult = ReturnType + +const { handlers, errorStore, activeWorkflow, dist, classifyCloud } = + vi.hoisted(() => ({ + handlers: {} as Record void>, + errorStore: { + clearExecutionStartErrors: () => {}, + clearPromptError: () => {} + } as Record, + activeWorkflow: { value: null as { path: string } | null }, + dist: { isCloud: false }, + classifyCloud: vi.fn<(_: string) => CloudValidationResult>(() => null) + })) + +vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } })) +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => { + handlers[name] = fn + }, + removeEventListener: () => {} + } +})) +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + isOpen: () => true, + openWorkflows: [], + nodeLocatorIdToNodeExecutionId: () => null, + get activeWorkflow() { + return activeWorkflow.value + } + }) +})) +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: undefined }) +})) +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => errorStore +})) +vi.mock('@/stores/nodeOutputStore', () => ({ + useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} }) +})) +vi.mock('@/composables/useAppMode', () => ({ + useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) }) +})) +vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined })) +vi.mock('@/utils/appMode', () => ({ + getWorkflowMode: () => 'workflow', + isAppModeValue: () => false +})) +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return dist.isCloud + } +})) +vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({ + resolveAccountPrecondition: () => null +})) +vi.mock('@/utils/executionErrorUtil', () => ({ + classifyCloudValidationError: classifyCloud +})) + +function setup() { + const store = useExecutionStore() + store.bindExecutionEvents() + return store +} + +function workflow(path: string): ComfyWorkflow { + return { path } as unknown as ComfyWorkflow +} + +function promptOutput(): ComfyApiWorkflow { + return {} +} + +beforeEach(() => { + setActivePinia(createPinia()) + for (const key of Object.keys(handlers)) delete handlers[key] + activeWorkflow.value = null + dist.isCloud = false + classifyCloud.mockReturnValue(null) + for (const k of ['lastPromptError', 'lastNodeErrors', 'lastExecutionError']) + delete errorStore[k] +}) + +describe('executionStore running state and error edges', () => { + it('lists jobs with a running node and counts running workflows', () => { + const store = setup() + handlers['progress_state']?.({ + detail: { + prompt_id: 'job-1', + nodes: { n1: { state: 'running', value: 1, max: 2 } } + } + }) + + expect(store.runningJobIds).toEqual(['job-1']) + expect(store.runningWorkflowCount).toBe(1) + }) + + it('does not report the active workflow as running when the path differs', () => { + const store = setup() + expect(store.isActiveWorkflowRunning).toBe(false) + + const wf = workflow('w.json') + activeWorkflow.value = { path: 'other.json' } + store.storeJob({ + nodes: [], + id: 'job-2', + promptOutput: promptOutput(), + workflow: wf + }) + handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } }) + + expect(store.isActiveWorkflowRunning).toBe(false) + }) + + it('reports the active workflow as running when job, path and session agree', () => { + const store = setup() + const wf = workflow('w.json') + activeWorkflow.value = { path: 'w.json' } + store.storeJob({ + nodes: [], + id: 'job-2', + promptOutput: promptOutput(), + workflow: wf + }) + handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } }) + + expect(store.isActiveWorkflowRunning).toBe(true) + }) + + it('formats a service-level error message from the exception message alone', () => { + setup() + handlers['execution_error']?.({ + detail: { prompt_id: 'job-3', exception_message: 'Job has stagnated' } + }) + + expect(errorStore.lastPromptError).toEqual({ + type: 'error', + message: 'Job has stagnated', + details: '' + }) + }) + + it('stores a classified cloud prompt error on the prompt-error branch', () => { + dist.isCloud = true + classifyCloud.mockReturnValue({ + kind: 'promptError', + promptError: { type: 'validation', message: 'bad input', details: '' } + }) + setup() + + handlers['execution_error']?.({ + detail: { prompt_id: 'job-4', exception_message: '{}' } + }) + + expect(errorStore.lastPromptError).toEqual({ + type: 'validation', + message: 'bad input', + details: '' + }) + }) +}) diff --git a/src/stores/executionStore.test.ts b/src/stores/executionStore.test.ts index fac8c4b149a..7cadf0410ac 100644 --- a/src/stores/executionStore.test.ts +++ b/src/stores/executionStore.test.ts @@ -127,9 +127,10 @@ vi.mock('@/scripts/api', () => ({ } })) +const revokePreviewsByExecutionId = vi.hoisted(() => vi.fn()) vi.mock('@/stores/nodeOutputStore', () => ({ useNodeOutputStore: () => ({ - revokePreviewsByExecutionId: vi.fn() + revokePreviewsByExecutionId }) })) @@ -423,6 +424,124 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => { 'running' ) }) + + it('keeps an existing error state when later progress maps to the same locator', () => { + store.nodeProgressStates = { + node1: { + display_node_id: '123', + state: 'error', + value: 0, + max: 100, + prompt_id: 'test', + node_id: 'node1' + }, + node2: { + display_node_id: '123:456', + state: 'running', + value: 50, + max: 100, + prompt_id: 'test', + node_id: 'node2' + } + } + + expect( + store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))] + .state + ).toBe('error') + }) + + it('ignores finished progress when current state is already running', () => { + store.nodeProgressStates = { + node1: { + display_node_id: '123', + state: 'running', + value: 5, + max: 10, + prompt_id: 'test', + node_id: 'node1' + }, + node2: { + display_node_id: '123', + state: 'finished', + value: 10, + max: 10, + prompt_id: 'test', + node_id: 'node2' + } + } + + expect( + store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))] + ).toMatchObject({ state: 'running', value: 5 }) + }) + + it('keeps later running progress from moving a locator backwards', () => { + store.nodeProgressStates = { + node1: { + display_node_id: '123', + state: 'running', + value: 6, + max: 10, + prompt_id: 'test', + node_id: 'node1' + }, + node2: { + display_node_id: '123', + state: 'running', + value: 8, + max: 10, + prompt_id: 'test', + node_id: 'node2' + } + } + + expect( + store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))] + ).toMatchObject({ state: 'running', value: 6, max: 10 }) + }) + + it('merges zero-max running progress without dividing by zero', () => { + store.nodeProgressStates = { + node1: { + display_node_id: '123', + state: 'pending', + value: 0, + max: 0, + prompt_id: 'test', + node_id: 'node1' + }, + node2: { + display_node_id: '123', + state: 'running', + value: 0, + max: 0, + prompt_id: 'test', + node_id: 'node2' + } + } + + expect( + store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))] + ).toMatchObject({ state: 'running', value: 0, max: 0 }) + }) + + it('skips nested progress when the execution id cannot be resolved', () => { + vi.mocked(app.rootGraph.getNodeById).mockReturnValue(null) + store.nodeProgressStates = { + node1: { + display_node_id: '404:1', + state: 'running', + value: 5, + max: 10, + prompt_id: 'test', + node_id: 'node1' + } + } + + expect(store.nodeLocationProgressStates).toHaveProperty('404') + expect(store.nodeLocationProgressStates).not.toHaveProperty('404:1') + }) }) describe('useExecutionStore - nodeProgressStatesByJob eviction', () => { @@ -551,6 +670,31 @@ describe('useExecutionStore - reconcileInitializingJobs', () => { expect(store.initializingJobIds).toEqual(new Set()) }) + + it('clears initialization ids directly', () => { + store.initializingJobIds = new Set(['job-1']) + + store.clearInitializationByJobId(null) + store.clearInitializationByJobId('missing') + store.clearInitializationByJobId('job-1') + + expect(store.initializingJobIds).toEqual(new Set()) + }) + + it('checks initializing jobs by stringified id', () => { + store.initializingJobIds = new Set(['7']) + + expect(store.isJobInitializing(undefined)).toBe(false) + expect(store.isJobInitializing(7)).toBe(true) + }) + + it('does not rewrite initializing state when no requested ids are tracked', () => { + store.initializingJobIds = new Set(['job-1']) + + store.clearInitializationByJobIds(['missing']) + + expect(store.initializingJobIds).toEqual(new Set(['job-1'])) + }) }) describe('useExecutionStore - workflowStatus', () => { @@ -675,6 +819,16 @@ describe('useExecutionStore - workflowStatus', () => { expect(store.getWorkflowStatus(workflowA)).toBe('completed') }) + it('leaves workflowStatus unchanged when open workflows are unchanged', async () => { + callStoreJob('job-a', workflowA) + fireExecutionSuccess('job-a') + + mockOpenWorkflows.value = [workflowA, workflowB] + await nextTick() + + expect(store.getWorkflowStatus(workflowA)).toBe('completed') + }) + it('sets failed on execution_error', () => { callStoreJob('job-1', workflowA) fireExecutionStart('job-1') @@ -691,6 +845,14 @@ describe('useExecutionStore - workflowStatus', () => { expect(store.getWorkflowStatus(workflowA)).toBeUndefined() }) + it('handles interrupt for a queued workflow with no active job', () => { + callStoreJob('job-1', workflowA) + + fireExecutionInterrupted('job-1') + + expect(store.getWorkflowStatus(workflowA)).toBeUndefined() + }) + it('evicts the oldest pending status once the buffer cap is exceeded', () => { // Each start with no matching storeJob buffers a 'running' status. One // past the cap evicts the oldest so the buffer can't grow unbounded. @@ -900,6 +1062,35 @@ describe('useExecutionStore - progress_text startup guard', () => { expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up') }) + + it('should ignore progress_text for another active prompt', async () => { + const mockNode = createMockLGraphNode({ id: 1 }) + const { useCanvasStore } = + await import('@/renderer/core/canvas/canvasStore') + useCanvasStore().canvas = { + graph: { getNodeById: vi.fn(() => mockNode) } + } as unknown as LGraphCanvas + store.activeJobId = 'job-1' + + fireProgressText({ + nodeId: toNodeId('1'), + text: 'warming up', + prompt_id: 'job-2' + }) + + expect(mockShowTextPreview).not.toHaveBeenCalled() + }) + + it('should ignore progress_text without text or node id', () => { + fireProgressText({ nodeId: toNodeId('1'), text: '' }) + fireProgressText({ + nodeId: '' as ReturnType, + text: 'warming up' + }) + + expect(mockShowTextPreview).not.toHaveBeenCalled() + }) + it('should ignore nested progress_text when the execution ID cannot be mapped', async () => { const { useCanvasStore } = await import('@/renderer/core/canvas/canvasStore') @@ -915,6 +1106,19 @@ describe('useExecutionStore - progress_text startup guard', () => { expect(mockExecutionIdToCurrentId).toHaveBeenCalledWith('1:2') expect(mockShowTextPreview).not.toHaveBeenCalled() }) + + it('should ignore progress_text when the current node id cannot be parsed', async () => { + const { useCanvasStore } = + await import('@/renderer/core/canvas/canvasStore') + useCanvasStore().canvas = { + graph: { getNodeById: vi.fn() } + } as unknown as LGraphCanvas + mockExecutionIdToCurrentId.mockReturnValue({}) + + fireProgressText({ nodeId: toNodeId('1:2'), text: 'warming up' }) + + expect(mockShowTextPreview).not.toHaveBeenCalled() + }) }) describe('useExecutionErrorStore - Node Error Lookups', () => { @@ -1375,6 +1579,21 @@ describe('useExecutionStore - WebSocket event handlers', () => { expect(store.initializingJobIds.has('job-1')).toBe(false) expect(store.initializingJobIds.has('job-2')).toBe(true) }) + + it('captures a queued workflow path when the start event wins the race', () => { + store.queuedJobs = { + 'job-1': { + nodes: {}, + workflow: createQueuedWorkflow('/workflows/race.json') + } + } + + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + + expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe( + '/workflows/race.json' + ) + }) }) describe('execution_cached', () => { @@ -1562,9 +1781,35 @@ describe('useExecutionStore - WebSocket event handlers', () => { is_app_mode: true }) }) + + it('uses current mode when shared queued job has no queued mode snapshot', () => { + mockAppModeState.mode.value = 'app' + mockAppModeState.isAppMode.value = true + store.queuedJobs = { + 'job-1': { + nodes: {}, + shareId: 'share-1' + } + } + + fire('execution_success', { prompt_id: 'job-1', timestamp: 0 }) + + expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({ + job_id: 'job-1', + share_id: 'share-1', + view_mode: 'app', + is_app_mode: true + }) + }) }) describe('executing', () => { + it('is a no-op when there is no active job', () => { + fire('executing', null) + + expect(store.activeJobId).toBeNull() + }) + it('clears _executingNodeProgress and activeJobId when detail is null', () => { fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) store._executingNodeProgress = { @@ -1590,7 +1835,32 @@ describe('useExecutionStore - WebSocket event handlers', () => { }) }) + describe('progress_state', () => { + it('does not revoke previews when the node execution id is invalid', () => { + fire('progress_state', { + prompt_id: 'job-1', + nodes: { + '': { + value: 1, + max: 2, + state: 'running', + node_id: '', + display_node_id: '', + prompt_id: 'job-1' + } + } + }) + + expect(store.nodeProgressStates).toHaveProperty('') + expect(revokePreviewsByExecutionId).not.toHaveBeenCalled() + }) + }) + describe('progress', () => { + it('reports null executing node progress before progress events arrive', () => { + expect(store.executingNodeProgress).toBeNull() + }) + it('sets _executingNodeProgress from the event payload', () => { const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' } @@ -1610,6 +1880,23 @@ describe('useExecutionStore - WebSocket event handlers', () => { expect(store.clientId).toBe('test-client') expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function)) }) + + it('keeps listening when status arrives before clientId is available', async () => { + const apiModule = await import('@/scripts/api') + const removeSpy = vi.mocked(apiModule.api.removeEventListener) + apiModule.api.clientId = '' + + fire('status', { exec_info: { queue_remaining: 0 } }) + + expect(store.clientId).toBeNull() + expect(removeSpy).not.toHaveBeenCalledWith('status', expect.any(Function)) + + apiModule.api.clientId = 'test-client' + fire('status', { exec_info: { queue_remaining: 0 } }) + + expect(store.clientId).toBe('test-client') + expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function)) + }) }) describe('execution_error', () => { @@ -1631,6 +1918,39 @@ describe('useExecutionStore - WebSocket event handlers', () => { }) }) + it('uses the message directly for service-level errors without a type', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: null, + exception_message: 'Job failed before node execution', + traceback: [] + }) + + expect(errorStore.lastPromptError).toMatchObject({ + type: 'error', + message: 'Job failed before node execution', + details: '' + }) + }) + + it('uses an empty prompt message for service-level errors without backend copy', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: null, + traceback: [] + }) + + expect(errorStore.lastPromptError).toMatchObject({ + type: 'error', + message: '', + details: '' + }) + }) + it('routes a runtime error (with node_id) to lastExecutionError', () => { const errorStore = useExecutionErrorStore() @@ -1744,6 +2064,12 @@ describe('useExecutionStore - WebSocket event handlers', () => { expect(store.initializingJobIds.has('job-9')).toBe(false) }) + + it('ignores notifications without text', () => { + fire('notification', { id: 'job-9' }) + + expect(store.initializingJobIds.has('job-9')).toBe(false) + }) }) describe('unbindExecutionEvents', () => { @@ -1813,6 +2139,45 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => { ) }) + it('storeJob works without workflow metadata', () => { + const workflow = {} as Parameters[0]['workflow'] + const missingWorkflow = undefined as unknown as Parameters< + typeof store.storeJob + >[0]['workflow'] + + store.storeJob({ + nodes: ['a'], + id: 'job-1', + promptOutput: { + a: createPromptNode('Node A', 'NodeA') + }, + workflow + }) + + expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false }) + expect(store.jobIdToWorkflowId.has('job-1')).toBe(false) + expect(store.jobIdToSessionWorkflowPath.has('job-1')).toBe(false) + + store.storeJob({ + nodes: ['b'], + id: 'job-2', + promptOutput: { + b: createPromptNode('Node B', 'NodeB') + }, + workflow: missingWorkflow + }) + + expect(store.queuedJobs['job-2']?.nodes).toEqual({ b: false }) + expect(store.queuedJobs['job-2']?.workflow).toBeUndefined() + }) + + it('reports zero execution progress for an active job with no nodes', () => { + store.activeJobId = 'job-1' + store.queuedJobs = { 'job-1': { nodes: {} } } + + expect(store.executionProgress).toBe(0) + }) + it('registerJobWorkflowIdMapping ignores empty inputs', () => { store.registerJobWorkflowIdMapping('job-1', 'wf-1') store.registerJobWorkflowIdMapping('', 'wf-2') @@ -1829,4 +2194,58 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => { expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json') }) + + it('evicts the oldest workflow paths when the session map exceeds capacity', () => { + for (let i = 0; i < 4001; i++) { + store.ensureSessionWorkflowPath(`job-${i}`, `/workflow-${i}.json`) + } + + expect(store.jobIdToSessionWorkflowPath.size).toBe(4000) + expect(store.jobIdToSessionWorkflowPath.has('job-0')).toBe(false) + expect(store.jobIdToSessionWorkflowPath.get('job-4000')).toBe( + '/workflow-4000.json' + ) + }) + + it('reports whether the active workflow is running', () => { + mockActiveWorkflow.value = { path: '/workflows/foo.json' } + store.activeJobId = 'job-1' + store.ensureSessionWorkflowPath('job-1', '/workflows/foo.json') + + expect(store.isActiveWorkflowRunning).toBe(true) + + store.ensureSessionWorkflowPath('job-1', '/workflows/bar.json') + expect(store.isActiveWorkflowRunning).toBe(false) + + mockActiveWorkflow.value = {} + expect(store.isActiveWorkflowRunning).toBe(false) + }) + + it('counts running jobs from progress state', () => { + store.nodeProgressStatesByJob = { + 'job-1': { + a: { + value: 1, + max: 10, + state: 'running', + node_id: 'a', + display_node_id: 'a', + prompt_id: 'job-1' + } + }, + 'job-2': { + b: { + value: 10, + max: 10, + state: 'finished', + node_id: 'b', + display_node_id: 'b', + prompt_id: 'job-2' + } + } + } + + expect(store.runningJobIds).toEqual(['job-1']) + expect(store.runningWorkflowCount).toBe(1) + }) }) diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index 3696130e0ba..82aa03db3e9 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -153,9 +153,9 @@ export const useExecutionStore = defineStore('execution', () => { pendingWorkflowStatusByJobId.delete(jobId) pendingWorkflowStatusByJobId.set(jobId, status) while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) { - const oldest = pendingWorkflowStatusByJobId.keys().next().value - if (oldest === undefined) break - pendingWorkflowStatusByJobId.delete(oldest) + pendingWorkflowStatusByJobId.delete( + pendingWorkflowStatusByJobId.keys().next().value as string + ) } } @@ -314,8 +314,8 @@ export const useExecutionStore = defineStore('execution', () => { : null ) - const activeJob = computed( - () => queuedJobs.value[activeJobId.value ?? ''] + const activeJob = computed(() => + activeJobId.value ? queuedJobs.value[activeJobId.value] : undefined ) const totalNodesToExecute = computed(() => { @@ -440,9 +440,7 @@ export const useExecutionStore = defineStore('execution', () => { // Update the executing nodes list if (e.detail == null) { - if (activeJobId.value) { - delete queuedJobs.value[activeJobId.value] - } + delete queuedJobs.value[activeJobId.value as JobId] activeJobId.value = null } } @@ -593,7 +591,7 @@ export const useExecutionStore = defineStore('execution', () => { function handleCloudValidationError( detail: ExecutionErrorWsMessage ): boolean { - const result = classifyCloudValidationError(detail.exception_message) + const result = classifyCloudValidationError(detail.exception_message ?? '') if (!result) return false clearInitializationByJobId(detail.prompt_id) @@ -669,17 +667,14 @@ export const useExecutionStore = defineStore('execution', () => { /** * Reset execution-related state after a run completes or is stopped. */ - function resetExecutionState(jobIdParam?: JobId | null) { + function resetExecutionState(jobId: JobId) { executionIdToLocatorCache.clear() nodeProgressStates.value = {} - const jobId = jobIdParam ?? activeJobId.value ?? null - if (jobId) { - const map = { ...nodeProgressStatesByJob.value } - delete map[jobId] - nodeProgressStatesByJob.value = map - useJobPreviewStore().clearPreview(jobId) - jobIdToWorkflow.delete(jobId) - } + const map = { ...nodeProgressStatesByJob.value } + delete map[jobId] + nodeProgressStatesByJob.value = map + useJobPreviewStore().clearPreview(jobId) + jobIdToWorkflow.delete(jobId) if (activeJobId.value) { delete queuedJobs.value[activeJobId.value] } @@ -771,9 +766,7 @@ export const useExecutionStore = defineStore('execution', () => { const next = new Map(jobIdToSessionWorkflowPath.value) next.set(jobId, path) while (next.size > MAX_SESSION_PATH_ENTRIES) { - const oldest = next.keys().next().value - if (oldest !== undefined) next.delete(oldest) - else break + next.delete(next.keys().next().value as JobId) } jobIdToSessionWorkflowPath.value = next } diff --git a/src/stores/executionWorkflowStatus.test.ts b/src/stores/executionWorkflowStatus.test.ts new file mode 100644 index 00000000000..26840845678 --- /dev/null +++ b/src/stores/executionWorkflowStatus.test.ts @@ -0,0 +1,153 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' +import { useExecutionStore } from '@/stores/executionStore' + +const { handlers, openSet } = vi.hoisted(() => ({ + handlers: {} as Record void>, + openSet: new Set() +})) + +vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } })) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => { + handlers[name] = fn + }, + removeEventListener: () => {} + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + isOpen: (workflow: unknown) => openSet.has(workflow), + openWorkflows: [], + nodeLocatorIdToNodeExecutionId: () => null + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: undefined }) +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => ({ + clearExecutionStartErrors: () => {}, + clearPromptError: () => {} + }) +})) + +vi.mock('@/composables/useAppMode', () => ({ + useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) }) +})) + +vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined })) + +vi.mock('@/utils/appMode', () => ({ + getWorkflowMode: () => 'workflow', + isAppModeValue: () => false +})) + +function workflow(path: string): ComfyWorkflow { + return { path } as unknown as ComfyWorkflow +} + +function promptOutput(): ComfyApiWorkflow { + return {} +} + +function storeJob( + store: ReturnType, + id: string, + wf: ComfyWorkflow +) { + store.storeJob({ nodes: [], id, promptOutput: promptOutput(), workflow: wf }) +} + +function fire(event: string, jobId: string) { + handlers[event]?.({ detail: { prompt_id: jobId } }) +} + +function setup() { + const store = useExecutionStore() + store.bindExecutionEvents() + return store +} + +beforeEach(() => { + setActivePinia(createPinia()) + for (const key of Object.keys(handlers)) delete handlers[key] + openSet.clear() +}) + +describe('executionStore workflow status', () => { + it('marks an open workflow running on execution_start and completed on success', () => { + const store = setup() + const wf = workflow('a.json') + openSet.add(wf) + storeJob(store, 'job-1', wf) + + fire('execution_start', 'job-1') + expect(store.getWorkflowStatus(wf)).toBe('running') + + fire('execution_success', 'job-1') + expect(store.getWorkflowStatus(wf)).toBe('completed') + }) + + it('buffers a status that arrives before the job is attached, then flushes on storeJob', () => { + const store = setup() + const wf = workflow('b.json') + openSet.add(wf) + + fire('execution_start', 'job-2') + expect(store.getWorkflowStatus(wf)).toBeUndefined() + + storeJob(store, 'job-2', wf) + expect(store.getWorkflowStatus(wf)).toBe('running') + }) + + it('does not apply status to a workflow that is not open', () => { + const store = setup() + const wf = workflow('c.json') + storeJob(store, 'job-3', wf) + + fire('execution_start', 'job-3') + expect(store.getWorkflowStatus(wf)).toBeUndefined() + }) + + it('clears a workflow status', () => { + const store = setup() + const wf = workflow('d.json') + openSet.add(wf) + storeJob(store, 'job-4', wf) + fire('execution_start', 'job-4') + expect(store.getWorkflowStatus(wf)).toBe('running') + + store.clearWorkflowStatus(wf) + expect(store.getWorkflowStatus(wf)).toBeUndefined() + }) + + it('does not let a late buffered running overwrite a terminal status', () => { + const store = setup() + const wf = workflow('e.json') + openSet.add(wf) + + storeJob(store, 'job-5', wf) + fire('execution_success', 'job-5') + expect(store.getWorkflowStatus(wf)).toBe('completed') + + fire('execution_start', 'job-6') + storeJob(store, 'job-6', wf) + expect(store.getWorkflowStatus(wf)).toBe('completed') + }) + + it('returns undefined for a null or unknown workflow', () => { + const store = setup() + expect(store.getWorkflowStatus(null)).toBeUndefined() + expect(store.getWorkflowStatus(workflow('unknown.json'))).toBeUndefined() + }) +}) diff --git a/src/stores/jobPreviewStore.test.ts b/src/stores/jobPreviewStore.test.ts index b10ff84d041..274040a0641 100644 --- a/src/stores/jobPreviewStore.test.ts +++ b/src/stores/jobPreviewStore.test.ts @@ -1,6 +1,6 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' +import { nextTick, ref } from 'vue' import { useJobPreviewStore } from '@/stores/jobPreviewStore' import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil' @@ -71,6 +71,14 @@ describe('jobPreviewStore', () => { expect(store.previewsByPromptId).toEqual({ p2: 'blob:b' }) }) + it('ignores clearPreview without a prompt id', () => { + const store = useJobPreviewStore() + + store.clearPreview(undefined) + + expect(store.nodePreviewsByPromptId).toEqual({}) + }) + it('clears all previews', () => { const store = useJobPreviewStore() store.setPreviewUrl('p1', 'blob:a', 'node-1') @@ -91,6 +99,24 @@ describe('jobPreviewStore', () => { expect(releaseSharedObjectUrl).not.toHaveBeenCalled() }) + it('ignores missing prompt ids', () => { + const store = useJobPreviewStore() + + store.setPreviewUrl(undefined, 'blob:a', 'node-1') + + expect(store.nodePreviewsByPromptId).toEqual({}) + }) + + it('releases the old url when replacing a preview', () => { + const store = useJobPreviewStore() + store.setPreviewUrl('p1', 'blob:a', 'node-1') + + store.setPreviewUrl('p1', 'blob:b', 'node-1') + + expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a') + expect(store.nodePreviewsByPromptId['p1']?.url).toBe('blob:b') + }) + it('ignores setPreviewUrl when previews are disabled', () => { previewMethodRef.value = 'none' const store = useJobPreviewStore() @@ -99,4 +125,15 @@ describe('jobPreviewStore', () => { expect(store.nodePreviewsByPromptId).toEqual({}) }) + + it('clears previews when previews are disabled after storage', async () => { + const store = useJobPreviewStore() + store.setPreviewUrl('p1', 'blob:a', 'node-1') + + previewMethodRef.value = 'none' + await nextTick() + + expect(store.nodePreviewsByPromptId).toEqual({}) + expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a') + }) }) diff --git a/src/stores/menuItemStore.test.ts b/src/stores/menuItemStore.test.ts new file mode 100644 index 00000000000..567174cfab1 --- /dev/null +++ b/src/stores/menuItemStore.test.ts @@ -0,0 +1,149 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import type { MenuItem } from 'primevue/menuitem' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCommandStore } from '@/stores/commandStore' +import { useMenuItemStore } from '@/stores/menuItemStore' + +const canvasStoreMock = vi.hoisted(() => ({ linearMode: false })) + +vi.mock('@/constants/coreMenuCommands', () => ({ + CORE_MENU_COMMANDS: [[['Core'], ['core.command']]] +})) + +vi.mock('@/composables/useErrorHandling', () => ({ + useErrorHandling: () => ({ + wrapWithErrorHandlingAsync: + (fn: () => Promise, errorHandler?: (e: unknown) => void) => + async () => { + try { + await fn() + } catch (e) { + if (errorHandler) errorHandler(e) + else throw e + } + } + }) +})) + +vi.mock('@/platform/keybindings/keybindingStore', () => ({ + useKeybindingStore: () => ({ + getKeybindingByCommandId: () => null + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => canvasStoreMock +})) + +describe('menuItemStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + canvasStoreMock.linearMode = false + }) + + it('records that linear mode has been seen', () => { + canvasStoreMock.linearMode = true + + const store = useMenuItemStore() + + expect(store.hasSeenLinear).toBe(true) + }) + + it('creates nested groups, separators, and active-state metadata', () => { + const store = useMenuItemStore() + const activeItem: MenuItem = { + label: 'Active', + comfyCommand: { id: 'active', function: vi.fn(), active: () => true } + } + const plainItem: MenuItem = { label: 'Plain' } + + store.registerMenuGroup(['File', 'Export'], [activeItem]) + store.registerMenuGroup(['File', 'Export'], [plainItem]) + + const file = store.menuItems[0] + const exportGroup = file.items?.[0] + + expect(file.label).toBe('File') + expect(exportGroup?.items).toEqual([ + activeItem, + { separator: true }, + plainItem + ]) + expect(store.menuItemHasActiveStateChildren['File.Export']).toBe(true) + }) + + it('repairs existing group items before appending children', () => { + const store = useMenuItemStore() + store.menuItems.push({ label: 'Tools' }) + + store.registerMenuGroup(['Tools'], [{ label: 'Child' }]) + + expect(store.menuItems[0].items).toEqual([{ label: 'Child' }]) + }) + + it('maps command ids to executable menu items', async () => { + const commandStore = useCommandStore() + const fn = vi.fn() + commandStore.registerCommand({ + id: 'test.command', + function: fn, + icon: 'icon-[lucide--test]', + label: 'Label', + menubarLabel: 'Menu Label', + tooltip: 'Tip' + }) + + const store = useMenuItemStore() + const item = store.commandIdToMenuItem('test.command', ['Tools']) + await item.command?.({ originalEvent: new Event('click'), item }) + + expect(fn).toHaveBeenCalled() + expect(item).toMatchObject({ + label: 'Menu Label', + icon: 'icon-[lucide--test]', + tooltip: 'Tip', + parentPath: 'Tools' + }) + }) + + it('loads extension menu commands only for commands owned by the extension', () => { + const commandStore = useCommandStore() + commandStore.registerCommand({ + id: 'owned', + function: vi.fn(), + menubarLabel: 'Owned' + }) + + const store = useMenuItemStore() + store.loadExtensionMenuCommands({ + name: 'extension', + commands: [{ id: 'owned', function: vi.fn() }], + menuCommands: [{ path: ['Tools'], commands: ['owned', 'external'] }] + }) + store.loadExtensionMenuCommands({ name: 'plain' }) + store.loadExtensionMenuCommands({ + name: 'empty', + menuCommands: [{ path: ['Tools'], commands: ['missing'] }] + }) + + expect(store.menuItems[0].items?.map((item) => item.label)).toEqual([ + 'Owned' + ]) + }) + + it('registers core menu commands', () => { + const commandStore = useCommandStore() + commandStore.registerCommand({ + id: 'core.command', + function: vi.fn(), + menubarLabel: 'Core Command' + }) + + const store = useMenuItemStore() + store.registerCoreMenuCommands() + + expect(store.menuItems[0].items?.[0].label).toBe('Core Command') + }) +}) diff --git a/src/stores/modelStore.test.ts b/src/stores/modelStore.test.ts index 22492bbabe5..5d0917a089a 100644 --- a/src/stores/modelStore.test.ts +++ b/src/stores/modelStore.test.ts @@ -137,6 +137,88 @@ describe('useModelStore', () => { expect(model.resolution).toBe('') }) + it('keeps the default model metadata when the server returns null', async () => { + enableMocks() + vi.mocked(api.viewMetadata).mockResolvedValueOnce(null) + store = useModelStore() + await store.loadModelFolders() + const folderStore = await store.getLoadedModelFolder('checkpoints') + const model = folderStore!.models['0/sdxl.safetensors'] + + await model.load() + + expect(model.title).toBe('sdxl') + expect(model.has_loaded_metadata).toBe(false) + }) + + it('loads model metadata once', async () => { + enableMocks() + store = useModelStore() + await store.loadModelFolders() + const folderStore = await store.getLoadedModelFolder('checkpoints') + const model = folderStore!.models['0/sdxl.safetensors'] + + await model.load() + await model.load() + + expect(api.viewMetadata).toHaveBeenCalledTimes(1) + }) + + it('keeps the default title when the first metadata key is empty', async () => { + enableMocks() + vi.mocked(api.viewMetadata).mockResolvedValueOnce({ + 'modelspec.title': '', + display_name: 'Fallback title' + }) + store = useModelStore() + await store.loadModelFolders() + const folderStore = await store.getLoadedModelFolder('checkpoints') + const model = folderStore!.models['0/sdxl.safetensors'] + + await model.load() + + expect(model.title).toBe('sdxl') + }) + + it('returns null for unknown loaded model folders', async () => { + enableMocks() + store = useModelStore() + await store.loadModelFolders() + + await expect(store.getLoadedModelFolder('missing')).resolves.toBeNull() + }) + + it('should read metadata from suffixed keys and ignore null values', async () => { + enableMocks() + vi.mocked(api.viewMetadata).mockResolvedValueOnce({ + 'custom.modelspec.title': 'Namespaced title', + 'custom.modelspec.author': null, + 'custom.modelspec.tags': null + }) + store = useModelStore() + await store.loadModelFolders() + const folderStore = await store.getLoadedModelFolder('checkpoints') + const model = folderStore!.models['0/sdxl.safetensors'] + + await model.load() + + expect(model.title).toBe('Namespaced title') + expect(model.author).toBe('') + expect(model.tags).toEqual(['']) + }) + + it('should keep extensions for non-safetensors files', async () => { + enableMocks() + vi.mocked(api.getModels).mockResolvedValueOnce([ + { name: 'notes.txt', pathIndex: 0 } + ]) + store = useModelStore() + await store.loadModelFolders() + const folderStore = await store.getLoadedModelFolder('checkpoints') + + expect(folderStore!.models['0/notes.txt'].title).toBe('notes.txt') + }) + it('should cache model information', async () => { enableMocks() store = useModelStore() @@ -209,6 +291,23 @@ describe('useModelStore', () => { expect(api.getModelFolders).toHaveBeenCalledTimes(2) expect(api.getModels).not.toHaveBeenCalled() }) + + it('does not reload previously loaded folders that disappear', async () => { + enableMocks() + store = useModelStore() + await store.loadModelFolders() + await store.getLoadedModelFolder('checkpoints') + vi.mocked(api.getModelFolders).mockResolvedValueOnce([ + { name: 'vae', folders: ['/path/to/vae'] } + ]) + + await store.refresh() + + expect(store.modelFolders.map((folder) => folder.directory)).toEqual([ + 'vae' + ]) + expect(api.getModels).toHaveBeenCalledTimes(1) + }) }) describe('API switching functionality', () => { diff --git a/src/stores/modelStore.ts b/src/stores/modelStore.ts index 4e92c12747c..108167ed11c 100644 --- a/src/stores/modelStore.ts +++ b/src/stores/modelStore.ts @@ -69,7 +69,9 @@ export class ComfyModelDef { this.path_index = pathIndex this.file_name = name this.normalized_file_name = name.replaceAll('\\', '/') - this.simplified_file_name = this.normalized_file_name.split('/').pop() ?? '' + this.simplified_file_name = this.normalized_file_name.slice( + this.normalized_file_name.lastIndexOf('/') + 1 + ) if (this.simplified_file_name.endsWith('.safetensors')) { this.simplified_file_name = this.simplified_file_name.slice( 0, diff --git a/src/stores/modelToNodeStore.test.ts b/src/stores/modelToNodeStore.test.ts index a968f22bdea..1f108477b30 100644 --- a/src/stores/modelToNodeStore.test.ts +++ b/src/stores/modelToNodeStore.test.ts @@ -138,6 +138,22 @@ describe('useModelToNodeStore', () => { expect(provider?.key).toBe('ckpt_name') }) + it('omits providers whose node definition is unavailable from reverse lookup', () => { + const modelToNodeStore = useModelToNodeStore() + modelToNodeStore.modelToNodeMap = { + missing: [ + new ModelNodeProvider( + undefined as unknown as ComfyNodeDefImpl, + 'model' + ) + ] + } + + expect(modelToNodeStore.getRegisteredNodeTypes()).not.toHaveProperty( + 'undefined' + ) + }) + it('should return undefined for unregistered model type', () => { const modelToNodeStore = useModelToNodeStore() modelToNodeStore.registerDefaults() @@ -577,6 +593,22 @@ describe('useModelToNodeStore', () => { expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined() }) + it('skips providers without node definitions during category lookup', () => { + const modelToNodeStore = useModelToNodeStore() + modelToNodeStore.modelToNodeMap = { + missing: [ + new ModelNodeProvider( + undefined as unknown as ComfyNodeDefImpl, + 'model' + ) + ] + } + + expect( + modelToNodeStore.getCategoryForNodeType('MissingNode') + ).toBeUndefined() + }) + it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => { const modelToNodeStore = useModelToNodeStore() modelToNodeStore.registerDefaults() diff --git a/src/stores/nodeBookmarkStore.test.ts b/src/stores/nodeBookmarkStore.test.ts new file mode 100644 index 00000000000..0c45e2cf447 --- /dev/null +++ b/src/stores/nodeBookmarkStore.test.ts @@ -0,0 +1,265 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' + +const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2' +const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization' + +const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({ + settings: {} as Record, + setSpy: vi.fn(), + nodeDefs: {} as Record +})) + +vi.mock('@/platform/settings/settingStore', async () => { + const { reactive } = await import('vue') + const reactiveSettings = reactive(settings) + setSpy.mockImplementation(async (id: string, value: unknown) => { + reactiveSettings[id] = value + }) + return { + useSettingStore: () => ({ + get: (id: string) => reactiveSettings[id], + set: setSpy + }) + } +}) + +vi.mock('@/stores/nodeDefStore', () => ({ + useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }), + buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }), + createDummyFolderNodeDef: (path: string) => ({ + isDummyFolder: true, + nodePath: path, + name: path + }) +})) + +type BookmarkNodeFixture = Pick< + ComfyNodeDefImpl, + 'isDummyFolder' | 'nodePath' | 'category' | 'name' +> + +function folderNode(nodePath: string) { + const node = { + isDummyFolder: true, + nodePath, + category: nodePath.replace(/\/$/, ''), + name: nodePath + } satisfies BookmarkNodeFixture + return node as ComfyNodeDefImpl +} + +function leafNode(name: string, nodePath = name) { + const node = { + isDummyFolder: false, + name, + nodePath, + category: '' + } satisfies BookmarkNodeFixture + return node as ComfyNodeDefImpl +} + +beforeEach(() => { + setActivePinia(createPinia()) + for (const key of Object.keys(settings)) delete settings[key] + for (const key of Object.keys(nodeDefs)) delete nodeDefs[key] + settings[BOOKMARK_ID] = [] + settings[CUSTOMIZATION_ID] = {} + setSpy.mockClear() +}) + +describe('nodeBookmarkStore', () => { + it('reports isBookmarked by either nodePath or top-level name', () => { + settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage'] + const store = useNodeBookmarkStore() + + expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe( + true + ) + expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true) + expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false) + }) + + it('adds a bookmark by appending to the current list', async () => { + settings[BOOKMARK_ID] = ['A'] + const store = useNodeBookmarkStore() + + await store.addBookmark('B') + + expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B']) + }) + + it('toggles an un-bookmarked node by adding its name', async () => { + const store = useNodeBookmarkStore() + + await store.toggleBookmark(leafNode('KSampler')) + + expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler']) + }) + + it('toggles a bookmarked node by deleting both nodePath and name', async () => { + settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler'] + const store = useNodeBookmarkStore() + + await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler')) + + expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler']) + expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, []) + expect(store.bookmarks).toEqual([]) + }) + + it('creates a folder under a parent and at the root', async () => { + const store = useNodeBookmarkStore() + + const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites') + expect(rootPath).toBe('Favorites/') + + const childPath = await store.addNewBookmarkFolder( + folderNode('Favorites/'), + 'Nested' + ) + expect(childPath).toBe('Favorites/Nested/') + }) + + it('parses each bookmark into its parent category, dropping unknown node defs', () => { + nodeDefs['LoadImage'] = leafNode('LoadImage') + nodeDefs['KSampler'] = leafNode('KSampler') + nodeDefs['Canny'] = leafNode('Canny') + settings[BOOKMARK_ID] = [ + 'LoadImage', + 'sampling/KSampler', + 'image/preprocessors/Canny', + 'sampling/Unknown', + 'Folder/' + ] + const store = useNodeBookmarkStore() + + const children = ( + store.bookmarkedRoot as unknown as { children: BookmarkNodeFixture[] } + ).children + + expect( + children.map((node) => + node.isDummyFolder ? node.nodePath : [node.name, node.category] + ) + ).toEqual([ + ['LoadImage', ''], + ['KSampler', 'sampling'], + ['Canny', 'image/preprocessors'], + 'Folder/' + ]) + }) + + describe('renameBookmarkFolder', () => { + it('rejects renaming a non-folder node', async () => { + const store = useNodeBookmarkStore() + await expect( + store.renameBookmarkFolder(leafNode('KSampler'), 'New') + ).rejects.toThrow('Cannot rename non-folder node') + }) + + it('rejects a name containing a slash', async () => { + const store = useNodeBookmarkStore() + await expect( + store.renameBookmarkFolder(folderNode('Old/'), 'a/b') + ).rejects.toThrow('cannot contain') + }) + + it('rejects a rename that collides with an existing folder', async () => { + settings[BOOKMARK_ID] = ['Taken/'] + const store = useNodeBookmarkStore() + await expect( + store.renameBookmarkFolder(folderNode('Old/'), 'Taken') + ).rejects.toThrow('already exists') + }) + + it('rewrites matching bookmark paths on a valid rename', async () => { + settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node'] + settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } } + const store = useNodeBookmarkStore() + + await store.renameBookmarkFolder(folderNode('Old/'), 'New') + + expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [ + 'New/', + 'New/KSampler', + 'Other/Node' + ]) + expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, { + 'New/': { color: '#abc' } + }) + }) + + it('does nothing when the folder keeps the same path', async () => { + const store = useNodeBookmarkStore() + + await store.renameBookmarkFolder(folderNode('Old/'), 'Old') + + expect(setSpy).not.toHaveBeenCalled() + }) + }) + + it('deletes a folder and all its descendants', async () => { + settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node'] + settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } } + const store = useNodeBookmarkStore() + + await store.deleteBookmarkFolder(folderNode('Old/')) + + expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node']) + expect(settings[BOOKMARK_ID]).toEqual(['Keep/Node']) + expect( + (settings[CUSTOMIZATION_ID] as Record)['Old/'] + ).toBeUndefined() + }) + + it('rejects deleting a non-folder node', async () => { + const store = useNodeBookmarkStore() + + await expect( + store.deleteBookmarkFolder(leafNode('KSampler')) + ).rejects.toThrow('Cannot delete non-folder node') + }) + + describe('updateBookmarkCustomization', () => { + it('persists a non-default customization', async () => { + const store = useNodeBookmarkStore() + + await store.updateBookmarkCustomization('Folder/', { + color: '#ff0000', + icon: 'pi-star' + }) + + expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, { + 'Folder/': { color: '#ff0000', icon: 'pi-star' } + }) + }) + + it('drops attributes set to their default values', async () => { + const store = useNodeBookmarkStore() + + await store.updateBookmarkCustomization('Folder/', { + color: store.defaultBookmarkColor, + icon: store.defaultBookmarkIcon + }) + + expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, { + 'Folder/': undefined + }) + }) + }) + + it('renames a customization entry, moving the old key to the new one', async () => { + settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } } + const store = useNodeBookmarkStore() + + await store.renameBookmarkCustomization('Old/', 'New/') + + expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, { + 'New/': { color: '#abc' } + }) + }) +}) diff --git a/src/stores/nodeBookmarkStore.ts b/src/stores/nodeBookmarkStore.ts index 75ba717ed45..574d582d250 100644 --- a/src/stores/nodeBookmarkStore.ts +++ b/src/stores/nodeBookmarkStore.ts @@ -50,9 +50,9 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => { .map((bookmark: string) => { if (bookmark.endsWith('/')) return createDummyFolderNodeDef(bookmark) - const parts = bookmark.split('/') - const name = parts.pop() ?? '' - const category = parts.join('/') + const slashIndex = bookmark.lastIndexOf('/') + const name = bookmark.slice(slashIndex + 1) + const category = bookmark.slice(0, Math.max(0, slashIndex)) const srcNodeDef = nodeDefStore.allNodeDefsByName[name] if (!srcNodeDef) { return null diff --git a/src/stores/nodeDefStore.test.ts b/src/stores/nodeDefStore.test.ts index 3098197e4de..ed56813ba65 100644 --- a/src/stores/nodeDefStore.test.ts +++ b/src/stores/nodeDefStore.test.ts @@ -1,16 +1,25 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' +import axios from 'axios' import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toRaw } from 'vue' import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils' -import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph' import { createTestSubgraph, createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' -import { useNodeDefStore } from '@/stores/nodeDefStore' +import { + ComfyNodeDefImpl, + buildNodeDefTree, + createDummyFolderNodeDef, + useNodeDefStore, + useNodeFrequencyStore +} from '@/stores/nodeDefStore' import type { NodeDefFilter } from '@/stores/nodeDefStore' describe('useNodeDefStore', () => { @@ -21,6 +30,10 @@ describe('useNodeDefStore', () => { store = useNodeDefStore() }) + afterEach(() => { + vi.restoreAllMocks() + }) + const createMockNodeDef = ( overrides: Partial = {} ): ComfyNodeDef => ({ @@ -39,7 +52,112 @@ describe('useNodeDefStore', () => { ...overrides }) + describe('ComfyNodeDefImpl', () => { + it('migrates defaultInput options and applies constructor fallbacks', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const nodeDef = createMockNodeDef({ + category: '_for_testing/coverage', + deprecated: undefined, + dev_only: undefined, + experimental: undefined, + help: undefined, + input: { + required: { prompt: ['STRING', { defaultInput: true }] }, + optional: { seed_override: ['INT', { defaultInput: true }] } + } + }) + + const impl = new ComfyNodeDefImpl(nodeDef) + + expect(warn).toHaveBeenCalledTimes(2) + expect(impl.help).toBe('') + expect(impl.experimental).toBe(true) + expect(impl.dev_only).toBe(false) + expect(impl.inputs.seed_override.forceInput).toBe(true) + }) + + it('derives empty-category node paths and lifecycle badges', () => { + const deprecated = new ComfyNodeDefImpl( + createMockNodeDef({ category: '', deprecated: undefined }) + ) + const beta = new ComfyNodeDefImpl( + createMockNodeDef({ experimental: true }) + ) + const dev = new ComfyNodeDefImpl(createMockNodeDef({ dev_only: true })) + const normal = new ComfyNodeDefImpl(createMockNodeDef()) + + expect(deprecated.nodePath).toBe('TestNode') + expect(deprecated.isDummyFolder).toBe(false) + expect(deprecated.nodeLifeCycleBadgeText).toBe('[DEPR]') + expect(beta.nodeLifeCycleBadgeText).toBe('[BETA]') + expect(dev.nodeLifeCycleBadgeText).toBe('[DEV]') + expect(normal.nodeLifeCycleBadgeText).toBe('') + }) + + it('defaults missing legacy input and output fields', () => { + const nodeDef = new ComfyNodeDefImpl( + fromAny({ + name: 'FallbackNode', + display_name: 'Fallback Node', + category: 'test', + python_module: 'test_module', + description: 'Test node', + output_node: false + }) + ) + + expect(nodeDef.input).toEqual({}) + expect(nodeDef.output).toEqual([]) + }) + + it('post-processes search scores with node frequency', async () => { + vi.spyOn(axios, 'get').mockResolvedValue({ data: { TestNode: 7 } }) + const frequencyStore = useNodeFrequencyStore() + await frequencyStore.loadNodeFrequencies() + const nodeDef = new ComfyNodeDefImpl(createMockNodeDef()) + + expect(nodeDef.postProcessSearchScores([10, 4, 2])).toEqual([ + 10, -7, 4, 2 + ]) + }) + }) + + describe('tree helpers', () => { + it('builds node definition trees from default and custom paths', () => { + const nodeDef = new ComfyNodeDefImpl( + createMockNodeDef({ name: 'TreeNode', category: 'root/branch' }) + ) + + expect(buildNodeDefTree([nodeDef]).children?.[0].label).toBe('root') + expect( + buildNodeDefTree([nodeDef], { + pathExtractor: (node) => ['custom', node.name] + }).children?.[0].label + ).toBe('custom') + }) + + it('normalizes dummy folder paths', () => { + expect(createDummyFolderNodeDef('folder/').category).toBe('folder') + expect(createDummyFolderNodeDef('folder').category).toBe('folder') + }) + }) + describe('filter registry', () => { + it('updates LiteGraph skip state for registered dev-only nodes', () => { + const registeredNodeTypes = LiteGraph.registered_node_types + LiteGraph.registered_node_types = fromAny({ + DevNode: { nodeData: { dev_only: true }, skip_list: false }, + NormalNode: { nodeData: {}, skip_list: false } + }) + + setActivePinia(createTestingPinia({ stubActions: false })) + useNodeDefStore() + + expect(LiteGraph.registered_node_types.DevNode.skip_list).toBe(true) + expect(LiteGraph.registered_node_types.NormalNode.skip_list).toBe(false) + LiteGraph.registered_node_types = registeredNodeTypes + }) + it('should register a new filter', () => { const filter: NodeDefFilter = { id: 'test.filter', @@ -287,6 +405,26 @@ describe('useNodeDefStore', () => { }) describe('allNodeDefsByName', () => { + it('keeps existing ComfyNodeDefImpl instances during updates', () => { + const nodeDef = new ComfyNodeDefImpl( + createMockNodeDef({ name: 'ExistingImpl' }) + ) + + store.updateNodeDefs([nodeDef]) + + expect(toRaw(store.nodeDefsByName.ExistingImpl)).toBe(nodeDef) + expect(toRaw(store.nodeDefsByDisplayName['Test Node'])).toBe(nodeDef) + }) + + it('adds one node definition to the name and display-name indexes', () => { + store.addNodeDef( + createMockNodeDef({ name: 'AddedNode', display_name: 'Added Node' }) + ) + + expect(store.nodeDefsByName.AddedNode.name).toBe('AddedNode') + expect(store.nodeDefsByDisplayName['Added Node'].name).toBe('AddedNode') + }) + it('should include all node defs by name', () => { const node1 = createMockNodeDef({ name: 'Node1' }) const node2 = createMockNodeDef({ name: 'Node2' }) @@ -336,6 +474,39 @@ describe('useNodeDefStore', () => { expect(store.allNodeDefsByName).toHaveProperty('Normal') expect(store.allNodeDefsByName).toHaveProperty('Deprecated') }) + + it('derives unique input and output data types', () => { + store.updateNodeDefs([ + createMockNodeDef({ + input: { + required: { image: ['IMAGE', {}] }, + optional: { mask: ['MASK', {}] } + }, + output: ['IMAGE', 'LATENT'], + output_is_list: [false, false], + output_name: ['image', 'latent'] + }) + ]) + + expect([...store.nodeDataTypes].sort()).toEqual([ + 'IMAGE', + 'LATENT', + 'MASK' + ]) + }) + + it('looks up node definitions from graph nodes and returns null for misses', () => { + store.updateNodeDefs([createMockNodeDef({ name: 'KnownNode' })]) + + expect( + store.fromLGraphNode(new LGraphNode('KnownNode', 'KnownNode'))?.name + ).toBe('KnownNode') + expect(store.fromLGraphNode(new LGraphNode('', ''))).toBeNull() + expect( + store.getInputSpecForWidget(new LGraphNode('Missing', 'Missing'), 'x') + ).toBeUndefined() + expect(store.nodeSearchService).toBeDefined() + }) }) describe('subgraph widget input specs', () => { @@ -389,6 +560,94 @@ describe('useNodeDefStore', () => { expect(spec?.type).toBe('STRING') expect(spec?.default).toBeUndefined() }) + + it('returns undefined for missing promoted subgraph inputs', () => { + const host = setupPromotedPrompt( + createMockNodeDef({ + name: 'PromptNode', + input: { required: { prompt: ['STRING', {}] } } + }) + ) + + expect(store.getInputSpecForWidget(host, 'missing')).toBeUndefined() + }) + + it('returns undefined when a subgraph input is not promoted', () => { + const subgraph = createTestSubgraph() + const host = createTestSubgraphNode(subgraph) + host.addInput('raw', 'STRING') + + expect(store.getInputSpecForWidget(host, 'raw')).toBeUndefined() + }) + + it('returns undefined when a promoted source no longer resolves', () => { + const host = setupPromotedPrompt( + createMockNodeDef({ + name: 'PromptNode', + input: { required: { prompt: ['STRING', {}] } } + }) + ) + host.subgraph.nodes[0].widgets = [] + + expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined() + }) + + it('returns undefined when concrete promoted widget resolution fails', async () => { + const resolver = + await import('@/core/graph/subgraph/resolveConcretePromotedWidget') + vi.spyOn(resolver, 'resolveConcretePromotedWidget').mockReturnValue( + fromAny({ status: 'failure', failure: 'missing-widget' }) + ) + const host = setupPromotedPrompt( + createMockNodeDef({ + name: 'PromptNode', + input: { required: { prompt: ['STRING', {}] } } + }) + ) + + expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined() + }) + }) + + describe('node frequency store', () => { + it('loads frequencies once and exposes top matching node definitions', async () => { + const get = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { RankedNode: 10, MissingNode: 3 } + }) + store.updateNodeDefs([createMockNodeDef({ name: 'RankedNode' })]) + const frequencyStore = useNodeFrequencyStore() + + await frequencyStore.loadNodeFrequencies() + await frequencyStore.loadNodeFrequencies() + + expect(get).toHaveBeenCalledTimes(1) + expect(frequencyStore.isLoaded).toBe(true) + expect(frequencyStore.getNodeFrequencyByName('RankedNode')).toBe(10) + expect( + frequencyStore.getNodeFrequency( + new ComfyNodeDefImpl(createMockNodeDef({ name: 'RankedNode' })) + ) + ).toBe(10) + expect(frequencyStore.getNodeFrequencyByName('Unknown')).toBe(0) + expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual( + ['RankedNode'] + ) + }) + + it('leaves frequency state unloaded when loading fails', async () => { + const error = new Error('boom') + vi.spyOn(axios, 'get').mockRejectedValue(error) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const frequencyStore = useNodeFrequencyStore() + + await frequencyStore.loadNodeFrequencies() + + expect(frequencyStore.isLoaded).toBe(false) + expect(errorSpy).toHaveBeenCalledWith( + 'Error loading node frequencies:', + error + ) + }) }) describe('performance', () => { diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index 0f9cb2a3c49..c11897b361c 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -105,8 +105,12 @@ export class ComfyNodeDefImpl * @internal * Migrate default input options to forceInput. */ - private static _migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 { - const def = _.cloneDeep(nodeDef) + private static _migrateDefaultInput( + nodeDef: ComfyNodeDefV1 + ): ComfyNodeDefV1 & { input: ComfyInputSpecV1 } { + const def = _.cloneDeep(nodeDef) as ComfyNodeDefV1 & { + input: ComfyInputSpecV1 + } def.input ??= {} // For required inputs, now we have the input socket always present. Specifying // it now has no effect. @@ -156,7 +160,7 @@ export class ComfyNodeDefImpl this.dev_only = obj.dev_only ?? false this.output_node = obj.output_node this.api_node = !!obj.api_node - this.input = obj.input ?? {} + this.input = obj.input this.output = obj.output ?? [] this.output_is_list = obj.output_is_list this.output_name = obj.output_name diff --git a/src/stores/nodeOutputStore.test.ts b/src/stores/nodeOutputStore.test.ts index bad7fc137ef..7674491b19e 100644 --- a/src/stores/nodeOutputStore.test.ts +++ b/src/stores/nodeOutputStore.test.ts @@ -3,15 +3,41 @@ import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { ExecutedWsMessage } from '@/schemas/apiSchema' import { app } from '@/scripts/app' import { useNodeOutputStore } from '@/stores/nodeOutputStore' -import { createNodeExecutionId } from '@/types/nodeIdentification' +import { + createNodeExecutionId, + createNodeLocatorId +} from '@/types/nodeIdentification' +import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' import { toNodeId } from '@/types/nodeId' import * as litegraphUtil from '@/utils/litegraphUtil' +const { + mockApiURL, + mockExecutionIdToNodeLocatorId, + mockNodeIdToNodeLocatorId, + mockNodeToNodeLocatorId, + mockReleaseSharedObjectUrl, + mockRetainSharedObjectUrl +} = vi.hoisted(() => ({ + mockApiURL: vi.fn((path: string) => `api${path}`), + mockExecutionIdToNodeLocatorId: vi.fn( + (_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId + ), + mockNodeIdToNodeLocatorId: vi.fn( + (id: string | number) => String(id) as NodeLocatorId + ), + mockNodeToNodeLocatorId: vi.fn( + (node: { id: string | number }) => String(node.id) as NodeLocatorId + ), + mockReleaseSharedObjectUrl: vi.fn(), + mockRetainSharedObjectUrl: vi.fn() +})) + const mockResolveNode = vi.fn() vi.mock('@/utils/litegraphUtil', () => ({ @@ -20,11 +46,25 @@ vi.mock('@/utils/litegraphUtil', () => ({ resolveNode: (...args: unknown[]) => mockResolveNode(...args) })) +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: (...args: Parameters) => mockApiURL(...args) + } +})) + +vi.mock('@/utils/objectUrlUtil', () => ({ + releaseSharedObjectUrl: (...args: [string | undefined]) => + mockReleaseSharedObjectUrl(...args), + retainSharedObjectUrl: (...args: [string | undefined]) => + mockRetainSharedObjectUrl(...args) +})) + const mockGetNodeById = vi.fn() vi.mock('@/scripts/app', () => ({ app: { getPreviewFormatParam: vi.fn(() => '&format=test_webp'), + getRandParam: vi.fn(() => '&rand=1'), rootGraph: { getNodeById: (...args: unknown[]) => mockGetNodeById(...args) }, @@ -49,13 +89,31 @@ const createMockOutputs = ( ): ExecutedWsMessage['output'] => ({ images }) vi.mock('@/utils/graphTraversalUtil', () => ({ - executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id) + executionIdToNodeLocatorId: ( + ...args: Parameters + ) => mockExecutionIdToNodeLocatorId(...args) })) +beforeEach(() => { + mockExecutionIdToNodeLocatorId.mockImplementation( + (_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId + ) + mockNodeIdToNodeLocatorId.mockImplementation( + (id: string | number) => String(id) as NodeLocatorId + ) + mockNodeToNodeLocatorId.mockImplementation( + (node: { id: string | number }) => String(node.id) as NodeLocatorId + ) +}) + vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ useWorkflowStore: vi.fn(() => ({ - nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)), - nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id)) + nodeIdToNodeLocatorId: ( + ...args: Parameters + ) => mockNodeIdToNodeLocatorId(...args), + nodeToNodeLocatorId: ( + ...args: Parameters + ) => mockNodeToNodeLocatorId(...args) })) })) @@ -780,6 +838,19 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => { expect(store.nodeOutputs['5']?.images?.[0]?.type).toBe('input') }) + it('ignores widget outputs when no locator can be resolved', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + mockNodeToNodeLocatorId.mockReturnValueOnce( + fromAny(undefined) + ) + + store.setNodeOutputs(node, 'test.png') + + expect(store.nodeOutputs).toEqual({}) + expect(app.nodeOutputs).toEqual({}) + }) + it('should skip empty array of filenames after createOutputs', () => { const store = useNodeOutputStore() const node = createMockNode({ id: 5 }) @@ -789,6 +860,470 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => { expect(store.nodeOutputs['5']).toBeUndefined() expect(app.nodeOutputs['5']).toBeUndefined() }) + + it('stores direct result items without wrapping them as image outputs', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + + store.setNodeOutputs(node, { filename: 'direct.png', type: 'temp' }) + + expect(store.nodeOutputs['5']).toEqual({ + filename: 'direct.png', + type: 'temp' + }) + }) + + it('marks animated webp and png filenames when requested', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + + store.setNodeOutputs(node, ['clip.webp', 'still.jpg', 'mask.png'], { + folder: 'output', + isAnimated: true + }) + + expect(store.nodeOutputs['5']?.animated).toEqual([true, false, true]) + expect(store.nodeOutputs['5']?.images?.map((image) => image.type)).toEqual([ + 'output', + 'output', + 'output' + ]) + }) +}) + +describe('nodeOutputStore image URLs', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false) + vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false) + app.nodeOutputs = {} + app.nodePreviewImages = {} + }) + + it('returns stored preview URLs before output URLs', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + + store.setNodePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)), [ + 'blob:preview' + ]) + + expect(store.getNodeImageUrls(node)).toEqual(['blob:preview']) + expect(mockApiURL).not.toHaveBeenCalled() + }) + + it('builds view URLs from output images', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + app.nodeOutputs['5'] = createMockOutputs( + fromAny([{ filename: 'a.png', subfolder: 'x', type: 'temp' }, null]) + ) + + expect(store.getNodeImageUrls(node)).toEqual([ + 'api/view?filename=a.png&subfolder=x&type=temp&format=test_webp&rand=1' + ]) + }) + + it('returns undefined when a node has neither previews nor outputs', () => { + const store = useNodeOutputStore() + + expect(store.getNodeImageUrls(createMockNode({ id: 5 }))).toBeUndefined() + }) + + it('returns execution previews before execution output URLs', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + const executionId = createNodeExecutionId([toNodeId(5)]) + + store.setNodePreviewsByExecutionId(executionId, ['blob:preview']) + + expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([ + 'blob:preview' + ]) + expect(store.latestPreview).toEqual(['blob:preview']) + expect(mockApiURL).not.toHaveBeenCalled() + }) + + it('falls back to execution output URLs when no preview exists', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + const executionId = createNodeExecutionId([toNodeId(5)]) + + store.setNodeOutputsByExecutionId( + executionId, + createMockOutputs([{ filename: 'result.png', type: 'temp' }]) + ) + + expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([ + 'api/view?filename=result.png&type=temp&format=test_webp&rand=1' + ]) + }) +}) + +describe('nodeOutputStore locator misses', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + app.nodeOutputs = {} + app.nodePreviewImages = {} + }) + + it('keeps execution operations inert when no locator can be resolved', () => { + const store = useNodeOutputStore() + const executionId = createNodeExecutionId([toNodeId(5)]) + mockExecutionIdToNodeLocatorId.mockReturnValue( + fromAny(undefined) + ) + + store.setNodeOutputsByExecutionId( + executionId, + createMockOutputs([{ filename: 'result.png' }]) + ) + store.setNodePreviewsByExecutionId(executionId, ['blob:preview']) + store.revokePreviewsByExecutionId(executionId) + + expect(store.getNodeOutputByExecutionId(executionId)).toBeUndefined() + expect(store.getNodePreviewImagesByExecutionId(executionId)).toBeUndefined() + expect(store.nodeOutputs).toEqual({}) + expect(store.nodePreviewImages).toEqual({}) + }) +}) + +describe('nodeOutputStore merge branches', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + app.nodeOutputs = {} + app.nodePreviewImages = {} + }) + + it('sets outputs when merge is requested without existing output', () => { + const store = useNodeOutputStore() + const executionId = createNodeExecutionId([toNodeId(5)]) + const output = createMockOutputs([{ filename: 'first.png' }]) + + store.setNodeOutputsByExecutionId(executionId, output, { merge: true }) + + expect(store.nodeOutputs[executionId]).toEqual(output) + }) + + it('ignores null outputs', () => { + const store = useNodeOutputStore() + const executionId = createNodeExecutionId([toNodeId(5)]) + + store.setNodeOutputsByExecutionId( + executionId, + fromAny(null) + ) + + expect(store.nodeOutputs[executionId]).toBeUndefined() + }) + + it('overwrites non-array fields during merge', () => { + const store = useNodeOutputStore() + const executionId = createNodeExecutionId([toNodeId(5)]) + const firstOutput: ExecutedWsMessage['output'] = { + images: [{ filename: 'first.png' }], + text: 'old' + } + + store.setNodeOutputsByExecutionId(executionId, firstOutput) + store.setNodeOutputsByExecutionId( + executionId, + { text: ['new'] }, + { merge: true } + ) + + expect(store.nodeOutputs[executionId]?.images).toEqual([ + { filename: 'first.png' } + ]) + expect(store.nodeOutputs[executionId]?.text).toEqual(['new']) + }) +}) + +describe('nodeOutputStore previews and removal', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + app.nodeOutputs = {} + app.nodePreviewImages = {} + }) + + it('releases old previews and retains new previews on replacement', () => { + const store = useNodeOutputStore() + const locatorId = createNodeLocatorId(null, toNodeId(5)) + + store.setNodePreviewsByLocatorId(locatorId, ['blob:first']) + store.setNodePreviewsByLocatorId(locatorId, ['blob:second']) + + expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first') + expect(mockRetainSharedObjectUrl).toHaveBeenCalledWith('blob:second') + expect(store.nodePreviewImages[locatorId]).toEqual(['blob:second']) + }) + + it('starts with an empty preview map when legacy previews are missing', () => { + app.nodePreviewImages = fromAny(undefined) + + const store = useNodeOutputStore() + + expect(store.nodePreviewImages).toEqual({}) + }) + + it('cancels scheduled revocation when a newer preview arrives', async () => { + vi.useFakeTimers() + const store = useNodeOutputStore() + const executionId = createNodeExecutionId([toNodeId(5)]) + + store.setNodePreviewsByExecutionId(executionId, ['blob:first']) + store.revokePreviewsByExecutionId(executionId) + store.setNodePreviewsByExecutionId(executionId, ['blob:second']) + await vi.advanceTimersByTimeAsync(400) + vi.useRealTimers() + + expect(store.nodePreviewImages[executionId]).toEqual(['blob:second']) + expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalledWith('blob:second') + }) + + it('revokes locator previews and clears preview state', () => { + const store = useNodeOutputStore() + const locatorId = createNodeLocatorId(null, toNodeId(5)) + + store.setNodePreviewsByLocatorId(locatorId, ['blob:first']) + store.revokePreviewsByLocatorId(locatorId) + + expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first') + expect(store.nodePreviewImages[locatorId]).toBeUndefined() + expect(app.nodePreviewImages[locatorId]).toBeUndefined() + }) + + it('leaves state unchanged when revoking a locator with no previews', () => { + const store = useNodeOutputStore() + + store.revokePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5))) + + expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled() + expect(store.nodePreviewImages).toEqual({}) + }) + + it('skips non-iterable preview entries when revoking all previews', () => { + const store = useNodeOutputStore() + app.nodePreviewImages = fromAny({ + '5': {}, + '6': ['blob:preview'] + }) + + store.revokeAllPreviews() + + expect(mockReleaseSharedObjectUrl).toHaveBeenCalledTimes(1) + expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview') + expect(store.nodePreviewImages).toEqual({}) + }) + + it('revokes subgraph previews for the parent node and child nodes', () => { + const store = useNodeOutputStore() + const subgraphId = '11111111-1111-1111-1111-111111111111' + const parentLocatorId = createNodeLocatorId(null, toNodeId(9)) + const childLocatorId = createNodeLocatorId(subgraphId, toNodeId(10)) + const subgraphNode = fromAny({ + id: toNodeId(9), + graph: { isRootGraph: true }, + subgraph: { + id: subgraphId, + nodes: [createMockNode({ id: 10 })] + } + }) + + store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent']) + store.setNodePreviewsByLocatorId(childLocatorId, ['blob:child']) + store.revokeSubgraphPreviews(subgraphNode) + + expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined() + expect(store.nodePreviewImages[childLocatorId]).toBeUndefined() + expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:parent') + expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:child') + }) + + it('uses the parent graph id for non-root subgraph preview revocation', () => { + const store = useNodeOutputStore() + const graphId = '22222222-2222-2222-2222-222222222222' + const subgraphId = '33333333-3333-3333-3333-333333333333' + const parentLocatorId = createNodeLocatorId(graphId, toNodeId(9)) + const subgraphNode = fromAny({ + id: toNodeId(9), + graph: { id: graphId, isRootGraph: false }, + subgraph: { id: subgraphId, nodes: [] } + }) + + store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent']) + store.revokeSubgraphPreviews(subgraphNode) + + expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined() + }) + + it('leaves previews alone when a subgraph node has no parent graph', () => { + const store = useNodeOutputStore() + const locatorId = createNodeLocatorId(null, toNodeId(9)) + const subgraphNode = fromAny({ + graph: undefined, + subgraph: { nodes: [] } + }) + + store.setNodePreviewsByLocatorId(locatorId, ['blob:parent']) + store.revokeSubgraphPreviews(subgraphNode) + + expect(store.nodePreviewImages[locatorId]).toEqual(['blob:parent']) + }) + + it('removes outputs and previews for a node id', () => { + const store = useNodeOutputStore() + const executionId = createNodeExecutionId([toNodeId(5)]) + + store.setNodeOutputsByExecutionId( + executionId, + createMockOutputs([{ filename: 'result.png' }]) + ) + store.setNodePreviewsByExecutionId(executionId, ['blob:preview']) + + expect(store.removeNodeOutputs(toNodeId(5))).toBe(true) + expect(store.nodeOutputs[executionId]).toBeUndefined() + expect(store.nodePreviewImages[executionId]).toBeUndefined() + expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview') + }) + + it('returns false when removing outputs for a node with no outputs', () => { + const store = useNodeOutputStore() + + expect(store.removeNodeOutputsForNode(createMockNode({ id: 9 }))).toBe( + false + ) + }) + + it('returns false when a node id cannot resolve to a locator', () => { + const store = useNodeOutputStore() + mockNodeIdToNodeLocatorId.mockReturnValueOnce( + fromAny(undefined) + ) + + expect(store.removeNodeOutputs(toNodeId(9))).toBe(false) + }) + + it('removes preview state even when preview entries are not iterable', () => { + const store = useNodeOutputStore() + const executionId = createNodeExecutionId([toNodeId(5)]) + + store.setNodeOutputsByExecutionId( + executionId, + createMockOutputs([{ filename: 'result.png' }]) + ) + app.nodePreviewImages[executionId] = fromAny({}) + store.nodePreviewImages[executionId] = fromAny({}) + + expect(store.removeNodeOutputs(toNodeId(5))).toBe(true) + expect(store.nodePreviewImages[executionId]).toBeUndefined() + expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled() + }) +}) + +describe('nodeOutputStore output refresh', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + app.nodeOutputs = {} + app.nodePreviewImages = {} + }) + + it('updates stored output images from legacy node images', () => { + const store = useNodeOutputStore() + const node = createMockNode({ + id: 5, + images: [{ filename: 'new.png', type: 'temp' }] + }) + + store.setNodeOutputsByExecutionId( + createNodeExecutionId([toNodeId(5)]), + createMockOutputs([{ filename: 'old.png', type: 'temp' }]) + ) + store.updateNodeImages(node) + + expect(store.nodeOutputs['5']?.images).toEqual([ + { filename: 'new.png', type: 'temp' } + ]) + }) + + it('ignores legacy image updates when the node has no images', () => { + const store = useNodeOutputStore() + + store.updateNodeImages(createMockNode({ id: 5 })) + + expect(store.nodeOutputs).toEqual({}) + }) + + it('ignores legacy image updates when no locator exists', () => { + const store = useNodeOutputStore() + mockNodeIdToNodeLocatorId.mockReturnValueOnce( + fromAny(undefined) + ) + + store.updateNodeImages( + createMockNode({ id: 5, images: [{ filename: 'new.png' }] }) + ) + + expect(store.nodeOutputs).toEqual({}) + }) + + it('ignores legacy image updates when no output exists', () => { + const store = useNodeOutputStore() + + store.updateNodeImages( + createMockNode({ id: 5, images: [{ filename: 'new.png' }] }) + ) + + expect(store.nodeOutputs).toEqual({}) + }) + + it('copies app outputs into reactive state during refresh', () => { + const store = useNodeOutputStore() + const node = createMockNode({ id: 5 }) + const output = createMockOutputs([{ filename: 'result.png' }]) + app.nodeOutputs['5'] = output + + store.refreshNodeOutputs(node) + + expect(store.nodeOutputs['5']).toEqual(output) + expect(store.nodeOutputs['5']).not.toBe(output) + }) + + it('does not refresh when a node has no locator', () => { + const store = useNodeOutputStore() + mockNodeToNodeLocatorId.mockReturnValueOnce( + fromAny(undefined) + ) + + store.refreshNodeOutputs(createMockNode({ id: 5 })) + + expect(store.nodeOutputs).toEqual({}) + }) + + it('does not refresh when app has no output for the node', () => { + const store = useNodeOutputStore() + + store.refreshNodeOutputs(createMockNode({ id: 5 })) + + expect(store.nodeOutputs).toEqual({}) + }) + + it('keeps unresolved restore output ids as their original ids', () => { + const store = useNodeOutputStore() + const output = createMockOutputs([{ filename: 'saved.png' }]) + mockExecutionIdToNodeLocatorId.mockReturnValueOnce( + fromAny(undefined) + ) + + store.restoreOutputs({ missing: output }) + + expect(store.nodeOutputs.missing).toEqual(output) + }) }) describe('nodeOutputStore syncLegacyNodeImgs', () => { @@ -894,4 +1429,20 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => { expect(mockNode.imgs).toEqual([mockImg]) expect(mockNode.imageIndex).toBe(0) }) + + it('copies output images onto the legacy node', () => { + LiteGraph.vueNodesMode = true + const store = useNodeOutputStore() + const mockNode = createMockNode({ id: 1 }) + const mockImg = document.createElement('img') + mockResolveNode.mockReturnValue(mockNode) + + store.setNodeOutputsByExecutionId( + createNodeExecutionId([toNodeId(1)]), + createMockOutputs([{ filename: 'result.png', type: 'temp' }]) + ) + store.syncLegacyNodeImgs(toNodeId(1), mockImg) + + expect(mockNode.images).toEqual([{ filename: 'result.png', type: 'temp' }]) + }) }) diff --git a/src/stores/previewExposureStore.test.ts b/src/stores/previewExposureStore.test.ts index b4c19014dc5..cb4524c4cd0 100644 --- a/src/stores/previewExposureStore.test.ts +++ b/src/stores/previewExposureStore.test.ts @@ -95,6 +95,22 @@ describe(usePreviewExposureStore, () => { expect(store.getExposures(rootGraphA, hostA)).toEqual([]) }) + + it('clears only the requested host when other hosts remain', () => { + store.addExposure(rootGraphA, hostA, { + sourceNodeId: '42', + sourcePreviewName: 'preview' + }) + store.addExposure(rootGraphA, hostB, { + sourceNodeId: '43', + sourcePreviewName: 'preview' + }) + + store.setExposures(rootGraphA, hostA, []) + + expect(store.getExposures(rootGraphA, hostA)).toEqual([]) + expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1) + }) }) describe('removeExposure', () => { @@ -122,6 +138,12 @@ describe(usePreviewExposureStore, () => { store.removeExposure(rootGraphA, hostA, 'does-not-exist') expect(store.getExposures(rootGraphA, hostA)).toEqual(before) }) + + it('is a no-op for an unknown host', () => { + store.removeExposure(rootGraphA, 'missing-host', 'preview') + + expect(store.getExposures(rootGraphA, 'missing-host')).toEqual([]) + }) }) describe('getExposuresAsPromotionShape', () => { diff --git a/src/stores/queueResultItem.test.ts b/src/stores/queueResultItem.test.ts new file mode 100644 index 00000000000..e8e0c19b6ef --- /dev/null +++ b/src/stores/queueResultItem.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { SerializedNodeId } from '@/types/nodeId' +import { ResultItemImpl } from '@/stores/queueStore' + +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: (path: string) => `http://localhost:8188${path}`, + addEventListener: () => {} + } +})) + +// Importing ResultItemImpl transitively loads @/scripts/app, whose module-level +// ComfyApp singleton wires real listeners. Stub it; ResultItemImpl needs none of it. +vi.mock('@/scripts/app', () => ({ app: {} })) + +// Keep preview-url assertions deterministic: don't append cloud params. +vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({ + appendCloudResParam: () => {} +})) + +interface ItemOverrides { + filename?: string + mediaType?: string + format?: string + frame_rate?: number +} + +function item(over: ItemOverrides = {}) { + return new ResultItemImpl({ + filename: over.filename ?? 'out.png', + subfolder: 'sub', + type: 'output', + nodeId: '1' as SerializedNodeId, + mediaType: over.mediaType ?? 'images', + format: over.format, + frame_rate: over.frame_rate + }) +} + +describe('ResultItemImpl', () => { + it('builds view url params and omits absent vhs fields', () => { + const params = item({ filename: 'a.png' }).urlParams + expect(params.get('filename')).toBe('a.png') + expect(params.get('type')).toBe('output') + expect(params.get('subfolder')).toBe('sub') + expect(params.has('format')).toBe(false) + expect(params.has('frame_rate')).toBe(false) + }) + + it('includes vhs format and frame_rate params when present', () => { + const params = item({ format: 'video/h264-mp4', frame_rate: 24 }).urlParams + expect(params.get('format')).toBe('video/h264-mp4') + expect(params.get('frame_rate')).toBe('24') + }) + + it('returns an empty url for a nameless item and a view url otherwise', () => { + expect(item({ filename: '' }).url).toBe('') + expect(item({ filename: 'a.png' }).url).toContain('/view?') + }) + + it('routes image preview urls through /view', () => { + expect( + item({ filename: 'a.png', mediaType: 'images' }).previewUrl + ).toContain('/view?') + }) + + it('exposes the vhs advanced preview endpoint', () => { + expect(item().vhsAdvancedPreviewUrl).toContain('/viewvideo?') + }) + + it('maps html video mime types by suffix and vhs format', () => { + expect(item({ filename: 'a.webm' }).htmlVideoType).toBe('video/webm') + expect(item({ filename: 'a.mp4' }).htmlVideoType).toBe('video/mp4') + expect(item({ filename: 'a.mov' }).htmlVideoType).toBe('video/quicktime') + expect( + item({ filename: 'a.bin', format: 'video/mp4', frame_rate: 24 }) + .htmlVideoType + ).toBe('video/mp4') + expect(item({ filename: 'a.txt' }).htmlVideoType).toBeUndefined() + }) + + it('maps html audio mime types by suffix', () => { + expect(item({ filename: 'a.mp3' }).htmlAudioType).toBe('audio/mpeg') + expect(item({ filename: 'a.wav' }).htmlAudioType).toBe('audio/wav') + expect(item({ filename: 'a.ogg' }).htmlAudioType).toBe('audio/ogg') + expect(item({ filename: 'a.flac' }).htmlAudioType).toBe('audio/flac') + expect(item({ filename: 'a.png' }).htmlAudioType).toBeUndefined() + }) + + it('treats vhs format as such only with both format and frame_rate', () => { + expect(item({ format: 'video/mp4', frame_rate: 24 }).isVhsFormat).toBe(true) + expect(item({ format: 'video/mp4' }).isVhsFormat).toBe(false) + }) + + it('classifies video by suffix and by media type', () => { + expect(item({ filename: 'a.webm' }).isVideo).toBe(true) + expect(item({ filename: 'a.bin', mediaType: 'video' }).isVideo).toBe(true) + expect(item({ filename: 'a.png', mediaType: 'video' }).isVideo).toBe(false) + }) + + it('classifies image only when not contradicted by a media suffix', () => { + expect(item({ filename: 'a.png', mediaType: 'images' }).isImage).toBe(true) + expect(item({ filename: 'a.webm', mediaType: 'images' }).isImage).toBe( + false + ) + }) + + it('classifies audio by suffix and by media type', () => { + expect(item({ filename: 'a.mp3' }).isAudio).toBe(true) + expect(item({ filename: 'a.bin', mediaType: 'audio' }).isAudio).toBe(true) + expect(item({ filename: 'a.png', mediaType: 'audio' }).isAudio).toBe(false) + }) + + it('reports text and preview support', () => { + const text = item({ filename: 'a.txt', mediaType: 'text' }) + expect(text.isText).toBe(true) + expect(text.supportsPreview).toBe(true) + expect(item({ filename: 'a.png' }).supportsPreview).toBe(true) + expect( + item({ filename: 'a.bin', mediaType: 'binary' }).supportsPreview + ).toBe(false) + }) + + it('filters previewable outputs and finds an item by url', () => { + const png = item({ filename: 'a.png' }) + const mp3 = item({ filename: 'b.mp3', mediaType: 'audio' }) + const bin = item({ filename: 'a.bin', mediaType: 'binary' }) + expect(ResultItemImpl.filterPreviewable([png, mp3, bin])).toEqual([ + png, + mp3 + ]) + + expect(ResultItemImpl.findByUrl([png, mp3, bin], png.url)).toBe(0) + expect(ResultItemImpl.findByUrl([png, mp3, bin], mp3.url)).toBe(1) + expect(ResultItemImpl.findByUrl([png, mp3, bin], 'no-match')).toBe(0) + expect(ResultItemImpl.findByUrl([png, mp3, bin])).toBe(0) + }) +}) diff --git a/src/stores/queueStore.loadWorkflow.test.ts b/src/stores/queueStore.loadWorkflow.test.ts index 313681a7007..3ceeb1040c9 100644 --- a/src/stores/queueStore.loadWorkflow.test.ts +++ b/src/stores/queueStore.loadWorkflow.test.ts @@ -1,5 +1,5 @@ import { createTestingPinia } from '@pinia/testing' -import { fromPartial } from '@total-typescript/shoehorn' +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -10,7 +10,11 @@ import type { import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyApp } from '@/scripts/app' import * as jobOutputCache from '@/services/jobOutputCache' +import type { TaskOutput } from '@/schemas/apiSchema' +import { useNodeOutputStore } from '@/stores/nodeOutputStore' import { TaskItemImpl } from '@/stores/queueStore' +import { createNodeExecutionId } from '@/types/nodeIdentification' +import { toNodeId } from '@/types/nodeId' vi.mock('@/services/extensionService', () => ({ useExtensionService: vi.fn(() => ({ @@ -44,7 +48,9 @@ const mockJobDetail = { } }, outputs: { - '1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] } + '1': { + images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }] + } } } @@ -137,4 +143,110 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => { expect(jobOutputCache.getJobDetail).toHaveBeenCalled() expect(mockApp.loadGraphData).not.toHaveBeenCalled() }) + + it('should load full outputs for history tasks', async () => { + const job = createHistoryJob('test-job-id') + const task = new TaskItemImpl(job) + vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue( + mockJobDetail as JobDetail + ) + + const loaded = await task.loadFullOutputs() + + expect(loaded).not.toBe(task) + expect(loaded.flatOutputs[0].filename).toBe('test.png') + }) + + it('should not load full outputs for running tasks', async () => { + const job = createRunningJob('test-job-id') + const task = new TaskItemImpl(job) + const detailSpy = vi.spyOn(jobOutputCache, 'getJobDetail') + + const loaded = await task.loadFullOutputs() + + expect(loaded).toBe(task) + expect(detailSpy).not.toHaveBeenCalled() + }) + + it('should keep history tasks when full outputs are unavailable', async () => { + const job = createHistoryJob('test-job-id') + const task = new TaskItemImpl(job) + vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue( + fromPartial({ id: 'test-job-id', status: 'completed' }) + ) + + const loaded = await task.loadFullOutputs() + + expect(loaded).toBe(task) + }) + + it('should load workflow outputs from the task when job detail has none', async () => { + const job = createHistoryJob('test-job-id') + const task = new TaskItemImpl(job, mockJobDetail.outputs) + const nodeOutputStore = useNodeOutputStore() + const setOutputsSpy = vi.spyOn( + nodeOutputStore, + 'setNodeOutputsByExecutionId' + ) + vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue( + fromPartial({ ...mockJobDetail, outputs: undefined }) + ) + + await task.loadWorkflow(mockApp) + + expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow) + expect(setOutputsSpy).toHaveBeenCalledOnce() + expect( + nodeOutputStore.getNodeOutputByExecutionId( + createNodeExecutionId([toNodeId(1)]) + ) + ).toEqual(mockJobDetail.outputs['1']) + }) + + it('should skip workflow output loading when no outputs exist', async () => { + const job = createHistoryJob('test-job-id') + const task = new TaskItemImpl(job, fromAny(null)) + const nodeOutputStore = useNodeOutputStore() + const setOutputsSpy = vi.spyOn( + nodeOutputStore, + 'setNodeOutputsByExecutionId' + ) + vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue( + fromPartial({ ...mockJobDetail, outputs: undefined }) + ) + + await task.loadWorkflow(mockApp) + + expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow) + expect(setOutputsSpy).not.toHaveBeenCalled() + expect(nodeOutputStore.nodeOutputs).toEqual({}) + }) + + it('should skip invalid node execution ids while loading outputs', async () => { + const job = createHistoryJob('test-job-id') + const outputs = fromAny({ + '': { images: [{ filename: 'skip.png', subfolder: '', type: 'output' }] }, + '1': { images: [{ filename: 'keep.png', subfolder: '', type: 'output' }] } + }) + const task = new TaskItemImpl(job, outputs) + const nodeOutputStore = useNodeOutputStore() + const setOutputsSpy = vi.spyOn( + nodeOutputStore, + 'setNodeOutputsByExecutionId' + ) + vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue( + fromPartial({ ...mockJobDetail, outputs: undefined }) + ) + + await task.loadWorkflow(mockApp) + + expect(setOutputsSpy).toHaveBeenCalledOnce() + expect(setOutputsSpy).toHaveBeenCalledWith('1', outputs['1']) + expect( + nodeOutputStore.getNodeOutputByExecutionId( + createNodeExecutionId([toNodeId(1)]) + ) + ).toEqual(outputs['1']) + expect(Object.keys(nodeOutputStore.nodeOutputs)).toEqual(['1']) + }) }) diff --git a/src/stores/queueStore.test.ts b/src/stores/queueStore.test.ts index 2f5a72611ae..0a32d28fa24 100644 --- a/src/stores/queueStore.test.ts +++ b/src/stores/queueStore.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -6,7 +7,14 @@ import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import type { TaskOutput } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { useExecutionStore } from '@/stores/executionStore' -import { TaskItemImpl, useQueueStore } from '@/stores/queueStore' +import { + isInstantMode, + isInstantRunningMode, + ResultItemImpl, + TaskItemImpl, + useQueuePendingTaskCountStore, + useQueueStore +} from '@/stores/queueStore' // Fixture factory for JobListItem function createJob( @@ -67,6 +75,86 @@ vi.mock('@/scripts/api', () => ({ })) describe('TaskItemImpl', () => { + it('should default missing result URL fields', () => { + const output = new ResultItemImpl( + fromAny[0], unknown>({ + nodeId: 'node-1', + mediaType: 'images' + }) + ) + + expect(output.filename).toBe('') + expect(output.subfolder).toBe('') + expect(output.type).toBe('') + expect(output.url).toBe('') + }) + + it('should use the raw URL as preview URL for non-images', () => { + const output = new ResultItemImpl({ + nodeId: 'node-1', + mediaType: 'video', + filename: 'clip.webm', + type: 'output', + subfolder: '' + }) + + expect(output.previewUrl).toBe(output.url) + }) + + it('should recognize VHS mp4 and unsupported video formats', () => { + const webm = new ResultItemImpl({ + nodeId: 'node-1', + mediaType: 'gifs', + filename: 'clip', + type: 'output', + subfolder: '', + format: 'video/webm', + frame_rate: 24 + }) + const mp4 = new ResultItemImpl({ + nodeId: 'node-1', + mediaType: 'gifs', + filename: 'clip', + type: 'output', + subfolder: '', + format: 'video/mp4', + frame_rate: 24 + }) + const avi = new ResultItemImpl({ + nodeId: 'node-1', + mediaType: 'gifs', + filename: 'clip', + type: 'output', + subfolder: '', + format: 'video/avi', + frame_rate: 24 + }) + + expect(webm.htmlVideoType).toBe('video/webm') + expect(mp4.htmlVideoType).toBe('video/mp4') + expect(avi.htmlVideoType).toBeUndefined() + }) + + it('should detect image media type without an image suffix', () => { + const image = new ResultItemImpl({ + nodeId: 'node-1', + mediaType: 'images', + filename: 'generated', + type: 'output', + subfolder: '' + }) + const audioFile = new ResultItemImpl({ + nodeId: 'node-1', + mediaType: 'images', + filename: 'generated.wav', + type: 'output', + subfolder: '' + }) + + expect(image.isImage).toBe(true) + expect(audioFile.isImage).toBe(false) + }) + it('should exclude animated from flatOutputs', () => { const job = createHistoryJob(0, 'job-id') const taskItem = new TaskItemImpl(job, { @@ -259,6 +347,41 @@ describe('TaskItemImpl', () => { expect(taskItem.executionError).toEqual(errorDetail) }) }) + + it('should expose queue API task type for running tasks', () => { + const task = new TaskItemImpl(createRunningJob(1, 'run-1')) + + expect(task.apiTaskType).toBe('queue') + }) + + it('should return empty flat outputs when outputs are missing', () => { + const task = new TaskItemImpl( + createHistoryJob(0, 'job-id'), + fromAny(null) + ) + + expect(task.calculateFlatOutputs()).toEqual([]) + }) + + it('should calculate execution time in seconds', () => { + const task = new TaskItemImpl({ + ...createHistoryJob(0, 'job-id'), + execution_start_time: 1000, + execution_end_time: 3500 + }) + + expect(task.executionStartTimestamp).toBe(1000) + expect(task.executionEndTimestamp).toBe(3500) + expect(task.executionTime).toBe(2500) + expect(task.executionTimeInSeconds).toBe(2.5) + }) + + it('should return undefined execution seconds without both timestamps', () => { + const task = new TaskItemImpl(createHistoryJob(0, 'job-id')) + + expect(task.executionTime).toBeUndefined() + expect(task.executionTimeInSeconds).toBeUndefined() + }) }) describe('useQueueStore', () => { @@ -314,6 +437,19 @@ describe('useQueueStore', () => { expect(store.pendingTasks[1].jobId).toBe('pend-1') }) + it('should register workflow ids for active jobs', async () => { + const executionStore = useExecutionStore() + mockGetQueue.mockResolvedValue({ + Running: [{ ...createRunningJob(1, 'run-1'), workflow_id: 'wf-1' }], + Pending: [] + }) + mockGetHistory.mockResolvedValue([]) + + await store.update() + + expect(executionStore.jobIdToWorkflowId.get('run-1')).toBe('wf-1') + }) + it('should load history tasks from API', async () => { const historyJob1 = createHistoryJob(5, 'hist-1') const historyJob2 = createHistoryJob(4, 'hist-2') @@ -1115,3 +1251,43 @@ describe('useQueueStore', () => { }) }) }) + +describe('useQueuePendingTaskCountStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('updates from status websocket messages', () => { + const store = useQueuePendingTaskCountStore() + + store.update( + fromAny({ + detail: { exec_info: { queue_remaining: 3 } } + }) + ) + + expect(store.count).toBe(3) + }) + + it('falls back to zero when status details are missing', () => { + const store = useQueuePendingTaskCountStore() + store.count = 3 + + store.update(fromAny({})) + + expect(store.count).toBe(0) + }) +}) + +describe('queue mode helpers', () => { + it('detect instant queue modes', () => { + expect(isInstantMode('instant-idle')).toBe(true) + expect(isInstantMode('instant-running')).toBe(true) + expect(isInstantMode('change')).toBe(false) + }) + + it('detect instant running mode', () => { + expect(isInstantRunningMode('instant-running')).toBe(true) + expect(isInstantRunningMode('instant-idle')).toBe(false) + }) +}) diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index 5bb7e9d37ab..026dc196585 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -273,9 +273,6 @@ export class TaskItemImpl { } calculateFlatOutputs(): ReadonlyArray { - if (!this.outputs) { - return [] - } return parseTaskOutput(this.outputs) } @@ -435,9 +432,6 @@ export class TaskItemImpl { // Use full outputs from job detail, or fall back to existing outputs const outputsToLoad = jobDetail?.outputs ?? this.outputs - if (!outputsToLoad) { - return - } const nodeOutputsStore = useNodeOutputStore() const rawOutputs = toRaw(outputsToLoad) diff --git a/src/stores/queueTaskItem.test.ts b/src/stores/queueTaskItem.test.ts new file mode 100644 index 00000000000..6cacef5679e --- /dev/null +++ b/src/stores/queueTaskItem.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' +import type { ResultItemType, TaskOutput } from '@/schemas/apiSchema' +import type { SerializedNodeId } from '@/types/nodeId' +import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore' + +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: (path: string) => `http://localhost:8188${path}`, + addEventListener: () => {} + } +})) + +vi.mock('@/scripts/app', () => ({ app: {} })) + +vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({ + appendCloudResParam: () => {} +})) + +const { parseTaskOutput } = vi.hoisted(() => ({ parseTaskOutput: vi.fn() })) +vi.mock('@/stores/resultItemParsing', () => ({ parseTaskOutput })) + +beforeEach(() => { + parseTaskOutput.mockClear() +}) + +type JobStatus = + | 'in_progress' + | 'pending' + | 'completed' + | 'failed' + | 'cancelled' + +function executionError( + overrides: Partial> = {} +): NonNullable { + return { + node_id: '1', + node_type: 'KSampler', + exception_message: 'boom', + exception_type: 'Error', + traceback: [], + current_inputs: {}, + current_outputs: {}, + ...overrides + } +} + +function job(over: Partial = {}): JobListItem { + return { + id: 'job-1', + status: 'completed', + create_time: 1000, + priority: 0, + ...over + } +} + +function result(filename: string, type: ResultItemType = 'output') { + return new ResultItemImpl({ + filename, + subfolder: '', + type, + nodeId: '1' as SerializedNodeId, + mediaType: 'images' + }) +} + +describe('TaskItemImpl', () => { + it('maps job status to taskType and apiTaskType', () => { + expect(new TaskItemImpl(job({ status: 'in_progress' })).taskType).toBe( + 'Running' + ) + expect(new TaskItemImpl(job({ status: 'pending' })).taskType).toBe( + 'Pending' + ) + expect(new TaskItemImpl(job({ status: 'completed' })).taskType).toBe( + 'History' + ) + + expect(new TaskItemImpl(job({ status: 'pending' })).apiTaskType).toBe( + 'queue' + ) + expect(new TaskItemImpl(job({ status: 'completed' })).apiTaskType).toBe( + 'history' + ) + }) + + it('exposes displayStatus for every backend status', () => { + const statuses: [JobStatus, string][] = [ + ['in_progress', 'Running'], + ['pending', 'Pending'], + ['completed', 'Completed'], + ['failed', 'Failed'], + ['cancelled', 'Cancelled'] + ] + for (const [status, display] of statuses) { + expect(new TaskItemImpl(job({ status })).displayStatus).toBe(display) + } + }) + + it('derives history/running flags and a status-qualified key', () => { + const running = new TaskItemImpl(job({ id: 'a', status: 'in_progress' })) + expect(running.isRunning).toBe(true) + expect(running.isHistory).toBe(false) + expect(running.key).toBe('aRunning') + + expect(new TaskItemImpl(job({ status: 'completed' })).isHistory).toBe(true) + }) + + it('uses explicitly provided flat outputs', () => { + const outputs = [result('a.png')] + const task = new TaskItemImpl(job(), undefined, outputs) + expect(task.flatOutputs).toBe(outputs) + }) + + it('parses outputs lazily when flat outputs are not supplied', () => { + const parsed = [result('p.png')] + parseTaskOutput.mockReturnValueOnce(parsed) + const outputs: TaskOutput = { '1': { images: [] } } + const task = new TaskItemImpl(job(), outputs) + expect(parseTaskOutput).toHaveBeenCalled() + expect(task.flatOutputs).toBe(parsed) + }) + + it('synthesizes outputs from preview_output when none are provided', () => { + parseTaskOutput.mockReturnValueOnce([]) + const preview = { nodeId: '5', mediaType: 'images', filename: 'prev.png' } + new TaskItemImpl(job({ preview_output: preview })) + expect(parseTaskOutput).toHaveBeenCalledWith({ + '5': { images: [preview] } + }) + }) + + it('prefers the last saved output over temp previews for previewOutput', () => { + const temp = result('temp.png', 'temp') + const saved = result('saved.png', 'output') + const task = new TaskItemImpl(job(), undefined, [temp, saved]) + expect(task.previewOutput).toBe(saved) + + const onlyTemp = new TaskItemImpl(job(), undefined, [temp]) + expect(onlyTemp.previewOutput).toBe(temp) + }) + + it('reports interrupted only for an interrupt-typed failure', () => { + expect( + new TaskItemImpl( + job({ + status: 'failed', + execution_error: executionError({ + exception_type: 'InterruptProcessingException' + }) + }) + ).interrupted + ).toBe(true) + expect( + new TaskItemImpl( + job({ + status: 'failed', + execution_error: executionError({ exception_type: 'Other' }) + }) + ).interrupted + ).toBe(false) + }) + + it('surfaces error message and passthrough job fields', () => { + const task = new TaskItemImpl( + job({ + status: 'failed', + outputs_count: 3, + workflow_id: 'wf-9', + execution_error: executionError({ exception_message: 'boom' }) + }) + ) + expect(task.errorMessage).toBe('boom') + expect(task.outputsCount).toBe(3) + expect(task.workflowId).toBe('wf-9') + }) + + it('computes execution time only when both timestamps exist', () => { + expect( + new TaskItemImpl( + job({ execution_start_time: 1000, execution_end_time: 3000 }) + ).executionTimeInSeconds + ).toBe(2) + expect( + new TaskItemImpl(job({ execution_start_time: 1000 })).executionTime + ).toBeUndefined() + }) + + it('flatten returns itself when not completed', () => { + const running = new TaskItemImpl(job({ status: 'in_progress' })) + expect(running.flatten()).toEqual([running]) + }) + + it('flatten expands a completed task into one task per output', () => { + const outputs = [result('a.png'), result('b.png')] + const task = new TaskItemImpl( + job({ id: 'j', status: 'completed' }), + undefined, + outputs + ) + + const flattened = task.flatten() + + expect(flattened).toHaveLength(2) + expect(flattened.map((t) => t.jobId)).toEqual(['j-0', 'j-1']) + }) +}) diff --git a/src/stores/resultItemParsing.test.ts b/src/stores/resultItemParsing.test.ts index 195854a2c05..923e4cdd5a7 100644 --- a/src/stores/resultItemParsing.test.ts +++ b/src/stores/resultItemParsing.test.ts @@ -1,4 +1,4 @@ -import { fromPartial } from '@total-typescript/shoehorn' +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import type { NodeExecutionOutput } from '@/schemas/apiSchema' @@ -154,6 +154,22 @@ describe(parseNodeOutput, () => { expect(result).toHaveLength(1) expect(result[0].filename).toBe('valid.png') }) + + it('excludes non-object and invalid-type items', () => { + const output = fromAny({ + images: [ + null, + 'not-an-item', + { filename: 'bad.png', type: 'invalid' }, + { filename: 'valid.png', type: 'output' } + ] + }) + + const result = parseNodeOutput('1', output) + + expect(result).toHaveLength(1) + expect(result[0].filename).toBe('valid.png') + }) }) describe(parseTaskOutput, () => { diff --git a/src/stores/subgraphNavigationStore.navigateToHash.test.ts b/src/stores/subgraphNavigationStore.navigateToHash.test.ts index 8fa22459a06..5de18ac11ea 100644 --- a/src/stores/subgraphNavigationStore.navigateToHash.test.ts +++ b/src/stores/subgraphNavigationStore.navigateToHash.test.ts @@ -3,6 +3,7 @@ import { fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' import type * as VueRouter from 'vue-router' @@ -102,12 +103,24 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ function makeSubgraph(id: string): Subgraph { return fromPartial({ id, + isRootGraph: false, rootGraph: app.rootGraph, _nodes: [], nodes: [] }) } +async function makeDuplicatedNavigationFailure(): Promise { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: {} }] + }) + await router.push('/') + const failure = await router.push('/') + if (!failure) throw new Error('Expected duplicated navigation failure') + return failure +} + async function flushHashWatcher() { await nextTick() await Promise.resolve() @@ -118,6 +131,7 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) vi.clearAllMocks() + vi.mocked(app.canvas.setGraph).mockReset() app.rootGraph.id = ids.root app.rootGraph.subgraphs.clear() app.canvas.subgraph = undefined @@ -230,6 +244,42 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => { warnSpy.mockRestore() }) + it('does not warn when recovery redirect hits a duplicated navigation', async () => { + routerMocks.replace.mockRejectedValueOnce( + await makeDuplicatedNavigationFailure() + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + app.canvas.graph = makeSubgraph(ids.deletedSubgraph) + useSubgraphNavigationStore() + + routeHashRef.value = `#${ids.deletedSubgraph}` + await vi.waitFor(() => + expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`) + ) + + expect(warnSpy).not.toHaveBeenCalledWith( + '[subgraphNavigation] router.replace rejected during recovery', + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('recovers to root when canvas is unavailable during redirect cleanup', async () => { + const appWithOptionalCanvas = app as unknown as { + canvas: typeof app.canvas | undefined + } + const canvas = appWithOptionalCanvas.canvas + appWithOptionalCanvas.canvas = undefined + useSubgraphNavigationStore() + + routeHashRef.value = '#not-a-valid-uuid' + await vi.waitFor(() => + expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`) + ) + + appWithOptionalCanvas.canvas = canvas + }) + it('redirects when a workflow load resolves but the subgraph is still missing', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) workflowStoreState.openWorkflows = [ @@ -304,4 +354,196 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => { expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph) warnSpy.mockRestore() }) + + it('updateHash does nothing on initial load with an empty hash', async () => { + const store = useSubgraphNavigationStore() + + await store.updateHash() + + expect(routerMocks.replace).not.toHaveBeenCalled() + expect(routerMocks.push).not.toHaveBeenCalled() + }) + + it('updateHash follows a non-empty initial subgraph hash', async () => { + const subgraph = makeSubgraph(ids.validSubgraph) + app.rootGraph.subgraphs.set(subgraph.id, subgraph) + vi.mocked(app.canvas.setGraph).mockImplementation((graph) => { + app.canvas.graph = graph + }) + routeHashRef.value = `#${ids.validSubgraph}` + const store = useSubgraphNavigationStore() + + await store.updateHash() + + expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph) + }) + + it('updateHash does not treat the initial root hash as a subgraph', async () => { + routeHashRef.value = `#${ids.root}` + app.canvas.graph = app.rootGraph + const store = useSubgraphNavigationStore() + + await store.updateHash() + + expect(workflowStoreState.activeSubgraph).toBeUndefined() + }) + + it('updateHash replaces an empty hash and pushes the active graph id', async () => { + const store = useSubgraphNavigationStore() + await store.updateHash() + app.canvas.graph = fromPartial({ id: ids.validSubgraph }) + + await store.updateHash() + + expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`) + expect(routerMocks.push).toHaveBeenCalledWith(`#${ids.validSubgraph}`) + }) + + it('updateHash skips router push when hash already matches the active graph', async () => { + const store = useSubgraphNavigationStore() + await store.updateHash() + routeHashRef.value = `#${ids.validSubgraph}` + app.canvas.graph = fromPartial({ id: ids.validSubgraph }) + + await store.updateHash() + + expect(routerMocks.push).not.toHaveBeenCalled() + }) + + it('updateHash skips router push when the active graph has no id', async () => { + const store = useSubgraphNavigationStore() + await store.updateHash() + routeHashRef.value = '#old' + app.canvas.graph = fromPartial({}) + + await store.updateHash() + + expect(routerMocks.push).not.toHaveBeenCalled() + }) + + it('updateHash warns when router push rejects', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + routerMocks.push.mockRejectedValueOnce(new Error('push failed')) + const store = useSubgraphNavigationStore() + await store.updateHash() + routeHashRef.value = '#old' + app.canvas.graph = fromPartial({ id: ids.validSubgraph }) + + await store.updateHash() + + expect(warnSpy).toHaveBeenCalledWith( + '[subgraphNavigation] router.push rejected', + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('updateHash ignores duplicated router push failures', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + routerMocks.push.mockRejectedValueOnce( + await makeDuplicatedNavigationFailure() + ) + const store = useSubgraphNavigationStore() + await store.updateHash() + routeHashRef.value = `#${ids.root}` + app.canvas.graph = fromPartial({ id: ids.validSubgraph }) + + await store.updateHash() + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('skips workflows without active state during hash recovery', async () => { + workflowStoreState.openWorkflows = [ + fromPartial({ path: 'inactive.json' }) + ] + useSubgraphNavigationStore() + + routeHashRef.value = `#${ids.deletedSubgraph}` + await vi.waitFor(() => + expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`) + ) + }) + + it('skips workflow states and subgraphs that do not match the hash', async () => { + workflowStoreState.openWorkflows = [ + fromPartial({ + path: 'other-workflow.json', + activeState: { + id: ids.validSubgraph, + definitions: { + subgraphs: [{ id: ids.validSubgraph }] + } + } + }) + ] + useSubgraphNavigationStore() + + routeHashRef.value = `#${ids.deletedSubgraph}` + await vi.waitFor(() => + expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`) + ) + }) + + it('handles workflow states with no subgraph definitions during recovery', async () => { + workflowStoreState.openWorkflows = [ + fromPartial({ + path: 'no-definitions.json', + activeState: { id: ids.validSubgraph } + }) + ] + useSubgraphNavigationStore() + + routeHashRef.value = `#${ids.deletedSubgraph}` + await vi.waitFor(() => + expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`) + ) + }) + + it('opens a workflow and navigates to the loaded root graph', async () => { + workflowStoreState.openWorkflows = [ + fromPartial({ + path: 'root-workflow.json', + activeState: { + id: ids.deletedSubgraph, + definitions: { subgraphs: [] } + } + }) + ] + workflowServiceMocks.openWorkflow.mockImplementation(async () => { + app.rootGraph.id = ids.deletedSubgraph + app.canvas.graph = fromPartial({ id: ids.root }) + }) + useSubgraphNavigationStore() + + routeHashRef.value = `#${ids.deletedSubgraph}` + await vi.waitFor(() => + expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph) + ) + }) + + it('does not reset the graph when loaded workflow is already active', async () => { + workflowStoreState.openWorkflows = [ + fromPartial({ + path: 'already-active.json', + activeState: { + id: ids.deletedSubgraph, + definitions: { subgraphs: [] } + } + }) + ] + workflowServiceMocks.openWorkflow.mockImplementation(async () => { + app.rootGraph.id = ids.deletedSubgraph + app.canvas.graph = fromPartial({ id: ids.deletedSubgraph }) + }) + useSubgraphNavigationStore() + + routeHashRef.value = `#${ids.deletedSubgraph}` + await vi.waitFor(() => + expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled() + ) + + expect(app.canvas.setGraph).not.toHaveBeenCalledWith(app.rootGraph) + }) }) diff --git a/src/stores/subgraphNavigationStore.ts b/src/stores/subgraphNavigationStore.ts index b74f55646a7..4dfa1042984 100644 --- a/src/stores/subgraphNavigationStore.ts +++ b/src/stores/subgraphNavigationStore.ts @@ -126,7 +126,6 @@ export const useSubgraphNavigationStore = defineStore( /** Apply a viewport state to the canvas. */ function applyViewport(viewport: DragAndScaleState): void { const canvas = app.canvas - if (!canvas) return canvas.ds.scale = viewport.scale canvas.ds.offset[0] = viewport.offset[0] canvas.ds.offset[1] = viewport.offset[1] @@ -170,7 +169,8 @@ export const useSubgraphNavigationStore = defineStore( if (!isWorkflowSwitching) { if (prevSubgraph) { saveViewport(prevSubgraph.id) - } else if (!prevSubgraph && subgraph) { + } + if (!prevSubgraph && subgraph) { saveViewport(getCurrentRootGraphId()) } } diff --git a/src/stores/subgraphNavigationStore.viewport.test.ts b/src/stores/subgraphNavigationStore.viewport.test.ts index 51dd745135c..c88ca3f06fa 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' @@ -136,6 +137,20 @@ 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 unknown as { + canvas: typeof app.canvas | undefined + } + appWithOptionalCanvas.canvas = undefined + + store.saveViewport('root') + + expect(store.viewportCache.has(':root')).toBe(false) + appWithOptionalCanvas.canvas = canvas + }) + it('saves viewport state for root graph', () => { const store = useSubgraphNavigationStore() mockCanvas.ds.state.scale = 2 @@ -164,6 +179,36 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => { }) describe('restoreViewport', () => { + it('does nothing when canvas is unavailable', () => { + const store = useSubgraphNavigationStore() + const canvas = app.canvas + const appWithOptionalCanvas = app as unknown as { + canvas: typeof app.canvas | undefined + } + appWithOptionalCanvas.canvas = undefined + + store.restoreViewport('root') + + expect(mockSetDirty).not.toHaveBeenCalled() + expect(rafCallbacks).toHaveLength(0) + appWithOptionalCanvas.canvas = canvas + }) + + it('does not apply cached viewport when canvas disappears', () => { + const store = useSubgraphNavigationStore() + const canvas = app.canvas + const appWithOptionalCanvas = app as unknown as { + canvas: typeof app.canvas | undefined + } + store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] }) + appWithOptionalCanvas.canvas = undefined + + store.restoreViewport('root') + + expect(mockSetDirty).not.toHaveBeenCalled() + appWithOptionalCanvas.canvas = canvas + }) + it('restores cached viewport', () => { const store = useSubgraphNavigationStore() store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] }) @@ -266,7 +311,10 @@ 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', + isRootGraph: false + }) rafCallbacks[1](performance.now()) expect(mockRequestSlotSyncAll).not.toHaveBeenCalled() @@ -283,7 +331,10 @@ 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', + isRootGraph: false + }) rafCallbacks[0](performance.now()) @@ -341,6 +392,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/stores/subgraphStore.test.ts b/src/stores/subgraphStore.test.ts index db322a8b0a4..91190596278 100644 --- a/src/stores/subgraphStore.test.ts +++ b/src/stores/subgraphStore.test.ts @@ -10,9 +10,14 @@ import { import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation' import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template' import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import { useSettingStore } from '@/platform/settings/settingStore' +import { useToastStore } from '@/platform/updates/common/toastStore' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import type { GlobalSubgraphData } from '@/scripts/api' import { api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' +import { useDialogService } from '@/services/dialogService' import { useLitegraphService } from '@/services/litegraphService' import { useNodeDefStore } from '@/stores/nodeDefStore' import { useSubgraphStore } from '@/stores/subgraphStore' @@ -36,6 +41,7 @@ vi.mock('@/scripts/api', () => ({ storeUserData: vi.fn(), listUserDataFullInfo: vi.fn(), getGlobalSubgraphs: vi.fn(), + deleteUserData: vi.fn(), apiURL: vi.fn(), addEventListener: vi.fn() } @@ -98,6 +104,12 @@ describe('useSubgraphStore', () => { setActivePinia(createTestingPinia({ stubActions: false })) store = useSubgraphStore() vi.clearAllMocks() + vi.mocked(useDialogService).mockReturnValue( + fromPartial>({ + prompt: vi.fn(() => 'testname'), + confirm: vi.fn(() => true) + }) + ) }) it('should allow publishing of a subgraph', async () => { @@ -134,6 +146,86 @@ describe('useSubgraphStore', () => { await store.publishSubgraph() expect(api.storeUserData).toHaveBeenCalled() }) + + it('rejects publishing when a single subgraph node is not selected', async () => { + vi.mocked(comfyApp.canvas).selectedItems = new Set() + + await expect(store.publishSubgraph()).rejects.toThrow( + 'Must have single SubgraphNode selected to publish' + ) + }) + + it('rejects publishing when serialization produces multiple nodes', async () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) + vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({ + nodes: [subgraphNode.serialize(), subgraphNode.serialize()], + subgraphs: [] + })) + + await expect(store.publishSubgraph()).rejects.toThrow( + 'Must have single SubgraphNode selected to publish' + ) + }) + + it('rejects publishing when the serialized node is not a subgraph node', async () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) + vi.mocked(comfyApp.canvas).draw = vi.fn() + vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({ + nodes: [{ ...subgraphNode.serialize(), type: 'missing' }], + subgraphs: [fromAny(subgraph.serialize())] + })) + + await expect(store.publishSubgraph('invalid')).rejects.toThrow( + 'Loaded subgraph blueprint does not contain valid subgraph' + ) + expect(api.storeUserData).not.toHaveBeenCalled() + }) + + it('does not publish when the name prompt is cancelled', async () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) + vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({ + nodes: [subgraphNode.serialize()], + subgraphs: [fromAny(subgraph.serialize())] + })) + vi.mocked(useDialogService).mockReturnValue( + fromPartial>({ + prompt: vi.fn(() => null), + confirm: vi.fn(() => true) + }) + ) + + await store.publishSubgraph() + + expect(api.storeUserData).not.toHaveBeenCalled() + }) + + it('does not overwrite an existing blueprint when confirmation is cancelled', async () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) + vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({ + nodes: [subgraphNode.serialize()], + subgraphs: [fromAny(subgraph.serialize())] + })) + vi.mocked(useDialogService).mockReturnValue( + fromPartial>({ + prompt: vi.fn(() => 'test'), + confirm: vi.fn(() => false) + }) + ) + await mockFetch({ 'test.json': mockGraph }) + + await store.publishSubgraph('test') + + expect(api.storeUserData).not.toHaveBeenCalled() + }) + it('should display published nodes in the node library', async () => { await mockFetch({ 'test.json': mockGraph }) expect( @@ -148,6 +240,30 @@ describe('useSubgraphStore', () => { //check active graph expect(comfyApp.loadGraphData).toHaveBeenCalled() }) + + it('switches into the nested subgraph when editing opens a wrapper graph', async () => { + await mockFetch({ 'test.json': mockGraph }) + const setGraph = vi.fn() + const nested = { id: 'nested' } + vi.mocked(comfyApp.canvas).graph = fromAny< + NonNullable, + unknown + >({ + nodes: [{ subgraph: nested }], + setGraph + }) + vi.mocked(comfyApp.canvas).setGraph = setGraph + + await store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'test') + + expect(setGraph).toHaveBeenCalledWith(nested) + }) + + it('throws when editing an unloaded blueprint', async () => { + await expect( + store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing') + ).rejects.toThrow('not yet loaded') + }) it('should allow subgraphs to be added to graph', async () => { //mock await mockFetch({ 'test.json': mockGraph }) @@ -166,6 +282,12 @@ describe('useSubgraphStore', () => { expect(second.nodes[0].id).not.toBe(-1) expect(second.definitions!.subgraphs![0].id).toBe('123') }) + + it('throws when getting an unloaded blueprint', () => { + expect(() => store.getBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')).toThrow( + 'not yet loaded' + ) + }) it('should identify user blueprints as non-global', async () => { await mockFetch({ 'test.json': mockGraph }) expect(store.isGlobalBlueprint('test')).toBe(false) @@ -188,6 +310,59 @@ describe('useSubgraphStore', () => { expect(store.isGlobalBlueprint('nonexistent')).toBe(false) }) + describe('deleteBlueprint', () => { + it('throws for unloaded blueprints', async () => { + await expect( + store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing') + ).rejects.toThrow('not yet loaded') + }) + + it('does not delete global blueprints', async () => { + await mockFetch( + {}, + { + global_bp: { + name: 'Global Blueprint', + info: { node_pack: 'comfy_essentials' }, + data: JSON.stringify(mockGraph) + } + } + ) + + await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'global_bp') + + expect(api.deleteUserData).not.toHaveBeenCalled() + expect(store.isGlobalBlueprint('global_bp')).toBe(true) + }) + + it('does not delete when confirmation is cancelled', async () => { + await mockFetch({ 'test.json': mockGraph }) + vi.mocked(useDialogService).mockReturnValue( + fromPartial>({ + prompt: vi.fn(() => 'testname'), + confirm: vi.fn(() => false) + }) + ) + + await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test') + + expect(api.deleteUserData).not.toHaveBeenCalled() + expect(store.isUserBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')).toBe(true) + }) + + it('deletes user blueprints after confirmation', async () => { + await mockFetch({ 'test.json': mockGraph }) + vi.mocked(api.deleteUserData).mockResolvedValue({ + status: 204 + } as Response) + + await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test') + + expect(api.deleteUserData).toHaveBeenCalledWith('subgraphs/test.json') + expect(store.isUserBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')).toBe(false) + }) + }) + describe('isUserBlueprint', () => { it('should return true for user blueprints', async () => { await mockFetch({ 'test.json': mockGraph }) @@ -285,6 +460,212 @@ describe('useSubgraphStore', () => { consoleSpy.mockRestore() }) + it('continues when global blueprint discovery rejects', async () => { + vi.mocked(api.listUserDataFullInfo).mockResolvedValue([]) + vi.mocked(api.getGlobalSubgraphs).mockRejectedValue( + new Error('global down') + ) + + await store.fetchSubgraphs() + + expect(store.subgraphBlueprints).toEqual([]) + }) + + it('reports compact detail when more than three blueprints fail', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const addToast = vi.spyOn(useToastStore(), 'add') + await mockFetch( + {}, + { + a: { name: 'A', info: { node_pack: 'test' }, data: '' }, + b: { name: 'B', info: { node_pack: 'test' }, data: '' }, + c: { name: 'C', info: { node_pack: 'test' }, data: '' }, + d: { name: 'D', info: { node_pack: 'test' }, data: '' } + } + ) + + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ detail: 'x4' }) + ) + }) + + it('ignores invalid user blueprint files during fetch', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await mockFetch({ + 'invalid.json': { + nodes: [], + definitions: { subgraphs: [] } + } + }) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load subgraph blueprint', + expect.any(Error) + ) + const error = consoleSpy.mock.calls.find( + ([message]) => message === 'Failed to load subgraph blueprint' + )?.[1] + expect(error).toBeInstanceOf(TypeError) + expect((error as Error).message).toBe( + "Subgraph blueprint 'invalid' must contain a root node" + ) + expect(store.subgraphBlueprints).toHaveLength(0) + consoleSpy.mockRestore() + }) + + it('rejects loaded blueprints whose wrapper node does not reference a subgraph', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await mockFetch({ + 'invalid-ref.json': { + nodes: [{ id: 1, type: 'missing' }], + definitions: { subgraphs: [{ id: 'present' }] } + } + }) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load subgraph blueprint', + expect.any(Error) + ) + expect(store.subgraphBlueprints).toHaveLength(0) + consoleSpy.mockRestore() + }) + + it('rejects loaded blueprints without subgraph definitions', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await mockFetch({ + 'missing-definitions.json': { + nodes: [{ id: 1, type: 'missing' }] + } + }) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load subgraph blueprint', + expect.any(Error) + ) + expect(store.subgraphBlueprints).toHaveLength(0) + consoleSpy.mockRestore() + }) + + it('rejects saving a blueprint whose active state has no subgraph definitions', async () => { + await mockFetch({ 'test.json': mockGraph }) + const blueprint = useWorkflowStore().getWorkflowByPath( + 'subgraphs/test.json' + ) + if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded') + blueprint.changeTracker!.activeState = fromAny({ + nodes: [{ id: 1, type: '123' }] + }) + + await expect(blueprint.save()).rejects.toThrow( + 'The root graph of a subgraph blueprint must consist of only a single subgraph node' + ) + }) + + it('marks non-blueprint root nodes when saving an invalid blueprint', async () => { + vi.mocked(comfyApp.canvas).draw = vi.fn() + await mockFetch({ 'test.json': mockGraph }) + const blueprint = useWorkflowStore().getWorkflowByPath( + 'subgraphs/test.json' + ) + if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded') + blueprint.changeTracker!.activeState = fromAny({ + nodes: [ + { id: 1, type: '123' }, + { id: 2, type: 'OtherNode' } + ], + definitions: { subgraphs: [{ id: '123' }] } + }) + + await expect(blueprint.save()).rejects.toThrow( + 'The root graph of a subgraph blueprint must consist of only a single subgraph node' + ) + expect(comfyApp.canvas.draw).toHaveBeenCalledWith(true, true) + }) + + it('does not save a loaded blueprint when first-save confirmation is cancelled', async () => { + const confirm = vi.fn(() => false) + vi.mocked(useDialogService).mockReturnValue( + fromPartial>({ + prompt: vi.fn(() => 'testname'), + confirm + }) + ) + useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] = + true + await mockFetch({ 'test.json': mockGraph }) + const blueprint = useWorkflowStore().getWorkflowByPath( + 'subgraphs/test.json' + ) + if (!blueprint) throw new Error('Blueprint was not loaded') + + const result = await blueprint.save() + + expect(result).toBe(blueprint) + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'overwriteBlueprint', + itemList: ['test'] + }) + ) + expect(api.storeUserData).not.toHaveBeenCalled() + }) + + it('saves a loaded blueprint after first-save confirmation', async () => { + const confirm = vi.fn(() => true) + vi.mocked(useDialogService).mockReturnValue( + fromPartial>({ + prompt: vi.fn(() => 'testname'), + confirm + }) + ) + useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] = + true + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => + Promise.resolve({ + path: 'subgraphs/test.json', + modified: Date.now(), + size: 2 + }) + } as Response) + await mockFetch({ 'test.json': mockGraph }) + const blueprint = useWorkflowStore().getWorkflowByPath( + 'subgraphs/test.json' + ) + if (!blueprint) throw new Error('Blueprint was not loaded') + + await blueprint.save() + + const [path, data, options] = vi.mocked(api.storeUserData).mock.calls[0] + if (typeof data !== 'string') throw new Error('Expected saved JSON') + expect(path).toBe('subgraphs/test.json') + expect(JSON.parse(data)).toMatchObject({ + nodes: [{ type: '123', title: 'test' }], + definitions: { subgraphs: [{ id: '123', name: 'test' }] } + }) + expect(options).toEqual({ + overwrite: true, + throwOnError: true, + full_info: true + }) + }) + + it('returns an already-loaded blueprint when loading without force', async () => { + await mockFetch({ 'test.json': mockGraph }) + const blueprint = useWorkflowStore().getWorkflowByPath( + 'subgraphs/test.json' + ) + if (!blueprint) throw new Error('Blueprint was not loaded') + + await blueprint.load() + + expect(api.getUserData).toHaveBeenCalledTimes(1) + }) + it('should handle global blueprint with rejected data promise gracefully', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await mockFetch( @@ -406,6 +787,29 @@ describe('useSubgraphStore', () => { expect(nodeDef?.description).toBe('This is a test blueprint') }) + it('does not copy workflowRendererVersion into subgraph metadata on load', async () => { + await mockFetch({ + 'metadata-load.json': { + nodes: [{ type: '123' }], + definitions: { + subgraphs: [{ id: '123', extra: {} }] + }, + extra: { + BlueprintDescription: 'Loaded description', + workflowRendererVersion: 'Vue' + } + } + }) + + const blueprint = store.getBlueprint( + BLUEPRINT_TYPE_PREFIX + 'metadata-load' + ) + + expect(blueprint.definitions!.subgraphs![0].extra).toEqual({ + BlueprintDescription: 'Loaded description' + }) + }) + it('should not duplicate metadata in both workflow extra and subgraph extra when publishing', async () => { const subgraph = createTestSubgraph() const subgraphNode = createTestSubgraphNode(subgraph) @@ -415,7 +819,8 @@ describe('useSubgraphStore', () => { // Set metadata on the subgraph's extra (as the commands do) subgraph.extra = { BlueprintDescription: 'Test description', - BlueprintSearchAliases: ['alias1', 'alias2'] + BlueprintSearchAliases: ['alias1', 'alias2'], + workflowRendererVersion: 'Vue' } vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) @@ -464,6 +869,7 @@ describe('useSubgraphStore', () => { const subgraphExtra = definitions.subgraphs[0]?.extra expect(subgraphExtra?.BlueprintDescription).toBeUndefined() expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined() + expect(subgraphExtra?.workflowRendererVersion).toBe('Vue') }) }) diff --git a/src/stores/subgraphStore.ts b/src/stores/subgraphStore.ts index 99fe96ef63b..b394b8cfacb 100644 --- a/src/stores/subgraphStore.ts +++ b/src/stores/subgraphStore.ts @@ -39,6 +39,10 @@ async function confirmOverwrite(name: string): Promise { }) } +type ValidSubgraphWorkflowJSON = ComfyWorkflowJSON & { + definitions: NonNullable +} + export const useSubgraphStore = defineStore('subgraph', () => { class SubgraphBlueprint extends ComfyWorkflow { static override readonly basePath = 'subgraphs/' @@ -54,18 +58,20 @@ export const useSubgraphStore = defineStore('subgraph', () => { this.hasPromptedSave = !confirmFirstSave } - validateSubgraph() { - if (!this.activeState?.definitions) + validateSubgraph(): ValidSubgraphWorkflowJSON { + const activeState = this.activeState + if (!activeState?.definitions) throw new Error( 'The root graph of a subgraph blueprint must consist of only a single subgraph node' ) - const { subgraphs } = this.activeState.definitions - const { nodes } = this.activeState + const validState = activeState as ValidSubgraphWorkflowJSON + const { subgraphs } = validState.definitions + const { nodes } = validState //Instanceof doesn't function as nodes are serialized function isSubgraphNode(node: ComfyNode) { return node && subgraphs.some((s) => s.id === node.type) } - if (nodes.length == 1 && isSubgraphNode(nodes[0])) return + if (nodes.length == 1 && isSubgraphNode(nodes[0])) return validState const errors: Record = {} //mark errors for all but first subgraph node let firstSubgraphFound = false @@ -88,7 +94,7 @@ export const useSubgraphStore = defineStore('subgraph', () => { } override async save(): Promise { - this.validateSubgraph() + const activeState = this.validateSubgraph() if ( !this.hasPromptedSave && useSettingStore().get('Comfy.Workflow.WarnBlueprintOverwrite') @@ -97,7 +103,7 @@ export const useSubgraphStore = defineStore('subgraph', () => { this.hasPromptedSave = true } // Extract metadata from subgraph.extra to workflow.extra before saving - this.extractMetadataToWorkflowExtra() + this.extractMetadataToWorkflowExtra(activeState) const ret = await super.save() // Force reload to update initialState with saved metadata registerNodeDef(await this.load({ force: true }), { @@ -110,13 +116,14 @@ export const useSubgraphStore = defineStore('subgraph', () => { * Moves all properties (except workflowRendererVersion) from subgraph.extra * to workflow.extra, then removes from subgraph.extra to avoid duplication. */ - private extractMetadataToWorkflowExtra(): void { - if (!this.activeState) return - const subgraph = this.activeState.definitions?.subgraphs?.[0] + private extractMetadataToWorkflowExtra( + activeState: ValidSubgraphWorkflowJSON + ): void { + const subgraph = activeState.definitions.subgraphs?.[0] if (!subgraph?.extra) return const sgExtra = subgraph.extra as Record - const workflowExtra = (this.activeState.extra ??= {}) as Record< + const workflowExtra = (activeState.extra ??= {}) as Record< string, unknown > @@ -129,10 +136,10 @@ export const useSubgraphStore = defineStore('subgraph', () => { } override async saveAs(path: string) { - this.validateSubgraph() + const activeState = this.validateSubgraph() this.hasPromptedSave = true // Extract metadata from subgraph.extra to workflow.extra before saving - this.extractMetadataToWorkflowExtra() + this.extractMetadataToWorkflowExtra(activeState) const ret = await super.saveAs(path) // Force reload to update initialState with saved metadata registerNodeDef(await this.load({ force: true }), { @@ -146,14 +153,20 @@ export const useSubgraphStore = defineStore('subgraph', () => { if (!force && this.isLoaded) return await super.load({ force }) const loaded = await super.load({ force }) const st = loaded.activeState + const rootNode = st.nodes[0] + if (!rootNode) { + throw new TypeError( + `Subgraph blueprint '${this.filename}' must contain a root node` + ) + } const sg = (st.definitions?.subgraphs ?? []).find( - (sg) => sg.id == st.nodes[0].type + (sg) => sg.id == rootNode.type ) if (!sg) throw new Error( 'Loaded subgraph blueprint does not contain valid subgraph' ) - sg.name = st.nodes[0].title = this.filename + sg.name = rootNode.title = this.filename // Copy blueprint metadata from workflow extra to subgraph extra // so it's available when editing via canvas.subgraph.extra @@ -277,7 +290,11 @@ export const useSubgraphStore = defineStore('subgraph', () => { name: string = workflow.filename ) { const subgraphNode = workflow.changeTracker.initialState.nodes[0] - if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint') + if (!subgraphNode) { + throw new TypeError( + `Subgraph blueprint '${name}' must contain a root node` + ) + } subgraphNode.inputs ??= [] subgraphNode.outputs ??= [] //NOTE: Types are cast to string. This is only used for input coloring on previews diff --git a/src/stores/systemStatsStore.test.ts b/src/stores/systemStatsStore.test.ts index 289667958c6..f8c831053cd 100644 --- a/src/stores/systemStatsStore.test.ts +++ b/src/stores/systemStatsStore.test.ts @@ -6,7 +6,7 @@ import type { SystemStats } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { useSystemStatsStore } from '@/stores/systemStatsStore' -const mockData = vi.hoisted(() => ({ isDesktop: false })) +const mockData = vi.hoisted(() => ({ isCloud: false, isDesktop: false })) // Mock the API vi.mock('@/scripts/api', () => ({ @@ -19,7 +19,9 @@ vi.mock('@/platform/distribution/types', () => ({ get isDesktop() { return mockData.isDesktop }, - isCloud: false + get isCloud() { + return mockData.isCloud + } })) describe('useSystemStatsStore', () => { @@ -138,6 +140,7 @@ describe('useSystemStatsStore', () => { describe('getFormFactor', () => { beforeEach(() => { // Reset systemStats for each test + mockData.isCloud = false store.systemStats = null }) @@ -162,6 +165,12 @@ describe('useSystemStatsStore', () => { expect(store.getFormFactor()).toBe('other') }) + it('should return "cloud" in cloud mode', () => { + mockData.isCloud = true + + expect(store.getFormFactor()).toBe('cloud') + }) + describe('desktop environment', () => { beforeEach(() => { mockData.isDesktop = true diff --git a/src/stores/templateRankingStore.test.ts b/src/stores/templateRankingStore.test.ts index 12e7950fad4..f86e85391c8 100644 --- a/src/stores/templateRankingStore.test.ts +++ b/src/stores/templateRankingStore.test.ts @@ -90,6 +90,12 @@ describe('templateRankingStore', () => { }) describe('computePopularScore', () => { + it('normalizes usage against itself before a largest score is loaded', () => { + const store = useTemplateRankingStore() + + expect(store.computePopularScore('2024-01-01', 10)).toBeGreaterThan(0.8) + }) + it('does not use searchRank', () => { const store = useTemplateRankingStore() store.largestUsageScore = 100 diff --git a/src/stores/topbarBadgeStore.test.ts b/src/stores/topbarBadgeStore.test.ts new file mode 100644 index 00000000000..c8ee3185cfa --- /dev/null +++ b/src/stores/topbarBadgeStore.test.ts @@ -0,0 +1,25 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useExtensionStore } from '@/stores/extensionStore' +import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore' + +describe('topbarBadgeStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('collects topbar badges from registered extensions', () => { + const extensionStore = useExtensionStore() + extensionStore.registerExtension({ + name: 'badges', + topbarBadges: [{ text: 'Beta', label: 'BETA' }] + }) + extensionStore.registerExtension({ name: 'plain' }) + + const store = useTopbarBadgeStore() + + expect(store.badges).toEqual([{ text: 'Beta', label: 'BETA' }]) + }) +}) diff --git a/src/stores/userFileStore.test.ts b/src/stores/userFileStore.test.ts index b94bd983be1..69e3ef95fc3 100644 --- a/src/stores/userFileStore.test.ts +++ b/src/stores/userFileStore.test.ts @@ -116,6 +116,33 @@ describe('useUserFileStore', () => { "Failed to load file 'file1.txt': 404 Not Found" ) }) + + it('should skip loading temporary and already loaded files', async () => { + const temporaryFile = UserFile.createTemporary('draft.txt') + const loadedFile = new UserFile('file1.txt', 123, 100) + loadedFile.content = 'content' + loadedFile.originalContent = 'content' + + await temporaryFile.load() + await loadedFile.load() + + expect(api.getUserData).not.toHaveBeenCalled() + }) + + it('should force reload loaded files', async () => { + const file = new UserFile('file1.txt', 123, 100) + file.content = 'old' + file.originalContent = 'old' + vi.mocked(api.getUserData).mockResolvedValue({ + status: 200, + text: () => Promise.resolve('new') + } as Response) + + await file.load({ force: true }) + + expect(api.getUserData).toHaveBeenCalledWith('file1.txt') + expect(file.content).toBe('new') + }) }) describe('save', () => { @@ -148,6 +175,60 @@ describe('useUserFileStore', () => { expect(api.storeUserData).not.toHaveBeenCalled() }) + + it('should save unmodified files when forced', async () => { + const file = new UserFile('file1.txt', 123, 100) + file.content = 'content' + file.originalContent = 'content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve('file1.txt') + } as Response) + + await file.save({ force: true }) + + expect(api.storeUserData).toHaveBeenCalledWith('file1.txt', 'content', { + throwOnError: true, + full_info: true, + overwrite: true + }) + expect(file.lastModified).toBe(123) + expect(file.size).toBe(100) + }) + + it('should normalize string modified times', async () => { + const file = new UserFile('file1.txt', 123, 100) + file.content = 'modified content' + file.originalContent = 'original content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => + Promise.resolve({ modified: '2024-01-02T03:04:05Z', size: 200 }) + } as Response) + + await file.save() + + expect(file.lastModified).toBe( + new Date('2024-01-02T03:04:05Z').getTime() + ) + expect(file.size).toBe(200) + }) + + it('should fall back when modified time is invalid', async () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(999) + const file = new UserFile('file1.txt', 123, 100) + file.content = 'modified content' + file.originalContent = 'original content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ modified: 'bad date', size: 200 }) + } as Response) + + await file.save() + + expect(file.lastModified).toBe(999) + dateNow.mockRestore() + }) }) describe('delete', () => { @@ -161,6 +242,26 @@ describe('useUserFileStore', () => { expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt') }) + + it('should skip deleting temporary files', async () => { + const file = UserFile.createTemporary('draft.txt') + + await file.delete() + + expect(api.deleteUserData).not.toHaveBeenCalled() + }) + + it('should throw when delete fails', async () => { + const file = new UserFile('file1.txt', 123, 100) + vi.mocked(api.deleteUserData).mockResolvedValue({ + status: 500, + statusText: 'Server Error' + } as Response) + + await expect(file.delete()).rejects.toThrow( + "Failed to delete file 'file1.txt': 500 Server Error" + ) + }) }) describe('rename', () => { @@ -181,6 +282,41 @@ describe('useUserFileStore', () => { expect(file.lastModified).toBe(456) expect(file.size).toBe(200) }) + + it('should rename temporary files locally', async () => { + const file = UserFile.createTemporary('draft.txt') + + await file.rename('renamed.txt') + + expect(api.moveUserData).not.toHaveBeenCalled() + expect(file.path).toBe('renamed.txt') + }) + + it('should throw when rename fails', async () => { + const file = new UserFile('file1.txt', 123, 100) + vi.mocked(api.moveUserData).mockResolvedValue({ + status: 409, + statusText: 'Conflict' + } as Response) + + await expect(file.rename('newfile.txt')).rejects.toThrow( + "Failed to rename file 'file1.txt': 409 Conflict" + ) + }) + + it('should leave metadata unchanged when rename returns a string', async () => { + const file = new UserFile('file1.txt', 123, 100) + vi.mocked(api.moveUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve('newfile.txt') + } as Response) + + await file.rename('newfile.txt') + + expect(file.path).toBe('newfile.txt') + expect(file.lastModified).toBe(123) + expect(file.size).toBe(100) + }) }) describe('saveAs', () => { @@ -207,6 +343,25 @@ describe('useUserFileStore', () => { expect(newFile.size).toBe(200) expect(newFile.content).toBe('file content') }) + + it('should save temporary files in place', async () => { + const file = UserFile.createTemporary('draft.txt') + file.content = 'file content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ modified: 456, size: 200 }) + } as Response) + + const newFile = await file.saveAs('newfile.txt') + + expect(api.storeUserData).toHaveBeenCalledWith( + 'draft.txt', + 'file content', + { throwOnError: true, full_info: true, overwrite: false } + ) + expect(newFile).toBe(file) + expect(newFile.path).toBe('draft.txt') + }) }) }) }) diff --git a/src/stores/userStore.test.ts b/src/stores/userStore.test.ts index 365fc53be4e..8c333c8048e 100644 --- a/src/stores/userStore.test.ts +++ b/src/stores/userStore.test.ts @@ -1,61 +1,72 @@ -import { createPinia, setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useUserStore } from './userStore' -const getUserConfig = vi.fn() +const apiMock = vi.hoisted(() => ({ + createUser: vi.fn(), + getUserConfig: vi.fn(), + user: undefined as string | undefined +})) vi.mock('@/scripts/api', () => ({ - api: { - getUserConfig: (...args: unknown[]) => getUserConfig(...args) - } + api: apiMock })) describe('userStore', () => { beforeEach(() => { - setActivePinia(createPinia()) - getUserConfig.mockReset() + setActivePinia(createTestingPinia({ stubActions: false })) + apiMock.createUser.mockReset() + apiMock.getUserConfig.mockReset() + apiMock.user = undefined localStorage.clear() }) describe('initialize', () => { + it('returns an empty user list before initialization', () => { + const store = useUserStore() + + expect(store.users).toEqual([]) + }) + it('fetches user config on first call', async () => { - getUserConfig.mockResolvedValue({}) + apiMock.getUserConfig.mockResolvedValue({}) const store = useUserStore() await store.initialize() - expect(getUserConfig).toHaveBeenCalledTimes(1) + expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1) expect(store.initialized).toBe(true) }) it('is a no-op once already initialized', async () => { - getUserConfig.mockResolvedValue({}) + apiMock.getUserConfig.mockResolvedValue({}) const store = useUserStore() await store.initialize() - getUserConfig.mockClear() + apiMock.getUserConfig.mockClear() await store.initialize() - expect(getUserConfig).not.toHaveBeenCalled() + expect(apiMock.getUserConfig).not.toHaveBeenCalled() }) it('retries on a subsequent call when the first fetch failed', async () => { - getUserConfig.mockRejectedValueOnce(new Error('network down')) - getUserConfig.mockResolvedValueOnce({}) + apiMock.getUserConfig.mockRejectedValueOnce(new Error('network down')) + apiMock.getUserConfig.mockResolvedValueOnce({}) const store = useUserStore() await expect(store.initialize()).rejects.toThrow('network down') expect(store.initialized).toBe(false) await expect(store.initialize()).resolves.toBeUndefined() - expect(getUserConfig).toHaveBeenCalledTimes(2) + expect(apiMock.getUserConfig).toHaveBeenCalledTimes(2) expect(store.initialized).toBe(true) }) it('deduplicates concurrent calls before the first fetch resolves', async () => { let resolveConfig: (value: unknown) => void = () => {} - getUserConfig.mockImplementation( + apiMock.getUserConfig.mockImplementation( () => new Promise((resolve) => { resolveConfig = resolve @@ -68,7 +79,100 @@ describe('userStore', () => { resolveConfig({}) await Promise.all([a, b]) - expect(getUserConfig).toHaveBeenCalledTimes(1) + expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1) + }) + + it('derives multi-user state and restores the current user from storage', async () => { + localStorage['Comfy.userId'] = 'user-2' + apiMock.getUserConfig.mockResolvedValue({ + users: { 'user-1': 'Ada', 'user-2': 'Grace' } + }) + const store = useUserStore() + + await store.initialize() + + expect(store.isMultiUserServer).toBe(true) + expect(store.needsLogin).toBe(false) + expect(store.users).toEqual([ + { userId: 'user-1', username: 'Ada' }, + { userId: 'user-2', username: 'Grace' } + ]) + expect(store.currentUser).toEqual({ userId: 'user-2', username: 'Grace' }) + await vi.waitFor(() => expect(apiMock.user).toBe('user-2')) + }) + + it('requires login on multi-user servers without a stored user', async () => { + apiMock.getUserConfig.mockResolvedValue({ + users: { 'user-1': 'Ada' } + }) + const store = useUserStore() + + await store.initialize() + + expect(store.needsLogin).toBe(true) + expect(store.currentUser).toBeNull() + expect(apiMock.user).toBeUndefined() + }) + }) + + describe('createUser', () => { + it('returns the created user id with the requested username', async () => { + apiMock.createUser.mockResolvedValue({ + json: () => Promise.resolve('user-1'), + status: 201 + }) + const store = useUserStore() + + await expect(store.createUser('Ada')).resolves.toEqual({ + userId: 'user-1', + username: 'Ada' + }) + }) + + it('throws API errors returned by user creation', async () => { + apiMock.createUser.mockResolvedValue({ + json: () => Promise.resolve({ error: 'name taken' }), + status: 409, + statusText: 'Conflict' + }) + const store = useUserStore() + + await expect(store.createUser('Ada')).rejects.toThrow('name taken') + }) + + it('throws a fallback error when user creation has no error body', async () => { + apiMock.createUser.mockResolvedValue({ + json: () => Promise.resolve({}), + status: 500, + statusText: 'Server Error' + }) + const store = useUserStore() + + await expect(store.createUser('Ada')).rejects.toThrow( + 'Error creating user: 500 Server Error' + ) + }) + }) + + describe('login/logout', () => { + it('persists login identity and clears it on logout', async () => { + const store = useUserStore() + + await store.login({ userId: 'user-1', username: 'Ada' }) + expect(localStorage['Comfy.userId']).toBe('user-1') + expect(localStorage['Comfy.userName']).toBe('Ada') + + await store.logout() + expect(localStorage['Comfy.userId']).toBeUndefined() + expect(localStorage['Comfy.userName']).toBeUndefined() + }) + + it('does not set api.user when login happens before user config loads', async () => { + const store = useUserStore() + + await store.login({ userId: 'user-1', username: 'Ada' }) + + expect(apiMock.user).toBeUndefined() }) }) }) diff --git a/src/stores/workspace/favoritedWidgetsStore.test.ts b/src/stores/workspace/favoritedWidgetsStore.test.ts new file mode 100644 index 00000000000..86ef72d88d1 --- /dev/null +++ b/src/stores/workspace/favoritedWidgetsStore.test.ts @@ -0,0 +1,258 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore' +import { toNodeId } from '@/types/nodeId' + +const { mockState } = vi.hoisted(() => ({ + mockState: { + graph: null as { extra: Record } | null, + nodes: {} as Record, + setDirty: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { + get rootGraph() { + return mockState.graph + } + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + activeWorkflow: undefined, + nodeToNodeLocatorId: (node: { id: unknown }) => String(node.id), + nodeIdToNodeLocatorId: (id: unknown) => String(id) + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: { setDirty: mockState.setDirty } }) +})) + +vi.mock('@/utils/graphTraversalUtil', () => ({ + getNodeByLocatorId: (_graph: unknown, id: string) => + mockState.nodes[id] ?? null +})) + +vi.mock('@/utils/nodeTitleUtil', () => ({ + resolveNodeDisplayName: (node: { title?: string }) => node.title ?? 'Node' +})) + +vi.mock('@/i18n', () => ({ + st: (_key: string, fallback: string) => fallback +})) + +interface FakeWidget { + name: string + label?: string +} + +function makeWidget({ name, label }: FakeWidget): IBaseWidget { + return { + name, + label, + options: {}, + type: 'number', + y: 0 + } as IBaseWidget +} + +function makeNode(id: number, widgets: FakeWidget[] = [], title = 'My Node') { + const node = new LGraphNode(title) + node.id = toNodeId(id) + node.title = title + node.widgets = widgets.map(makeWidget) + return node +} + +function registerNode(node: { id: unknown }) { + mockState.nodes[String(node.id)] = node +} + +beforeEach(() => { + setActivePinia(createPinia()) + mockState.graph = { extra: {} } + mockState.nodes = {} + mockState.setDirty = vi.fn() +}) + +describe('favoritedWidgetsStore', () => { + it('adds a favorite, marks workflow dirty, and persists to graph.extra', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + + store.addFavorite(node, 'seed') + + expect(store.isFavorited(node, 'seed')).toBe(true) + expect(mockState.setDirty).toHaveBeenCalledWith(true, true) + expect(mockState.graph?.extra.favoritedWidgets).toEqual({ + favorites: [{ nodeLocatorId: '1', widgetName: 'seed' }] + }) + }) + + it('does not add the same favorite twice', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + + store.addFavorite(node, 'seed') + const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets) + const dirtyCalls = mockState.setDirty.mock.calls.length + + store.addFavorite(node, 'seed') + + expect(store.favoritedWidgets).toHaveLength(1) + expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted) + expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls) + }) + + it('removes a favorite and treats removing an absent one as a no-op', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets) + const dirtyCalls = mockState.setDirty.mock.calls.length + + store.removeFavorite(node, 'missing') + expect(store.isFavorited(node, 'seed')).toBe(true) + expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted) + expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls) + + store.removeFavorite(node, 'seed') + expect(store.isFavorited(node, 'seed')).toBe(false) + }) + + it('toggles favorite state in both directions', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + + store.toggleFavorite(node, 'seed') + expect(store.isFavorited(node, 'seed')).toBe(true) + + store.toggleFavorite(node, 'seed') + expect(store.isFavorited(node, 'seed')).toBe(false) + }) + + it('resolves a valid favorite to a node/widget with a composed label', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(7, [{ name: 'cfg', label: 'CFG Scale' }], 'KSampler') + registerNode(node) + + store.addFavorite(node, 'cfg') + + const [resolved] = store.favoritedWidgets + expect(resolved.label).toBe('KSampler / CFG Scale') + expect(store.validFavoritedWidgets).toHaveLength(1) + }) + + it('labels favorites whose node was deleted and excludes them from valid', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(2, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + + delete mockState.nodes['2'] + + expect(store.favoritedWidgets[0].label).toContain('(node deleted)') + expect(store.validFavoritedWidgets).toHaveLength(0) + }) + + it('labels favorites whose widget no longer exists', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(3, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + + mockState.nodes['3'] = makeNode(3, [], 'My Node') + + expect(store.favoritedWidgets[0].label).toContain('(widget not found)') + }) + + it('prunes invalid favorites while keeping valid ones', () => { + const store = useFavoritedWidgetsStore() + const valid = makeNode(1, [{ name: 'seed' }]) + const stale = makeNode(2, [{ name: 'steps' }]) + registerNode(valid) + registerNode(stale) + store.addFavorite(valid, 'seed') + store.addFavorite(stale, 'steps') + + delete mockState.nodes['2'] + store.pruneInvalidFavorites() + + expect(store.favoritedWidgets).toHaveLength(1) + expect(store.isFavorited(valid, 'seed')).toBe(true) + }) + + it('reorders favorites to match the provided order', () => { + const store = useFavoritedWidgetsStore() + const a = makeNode(1, [{ name: 'seed' }]) + const b = makeNode(2, [{ name: 'steps' }]) + registerNode(a) + registerNode(b) + store.addFavorite(a, 'seed') + store.addFavorite(b, 'steps') + + store.reorderFavorites([...store.validFavoritedWidgets].reverse()) + + expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([ + '2', + '1' + ]) + }) + + it('clears all favorites', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + + store.clearFavorites() + + expect(store.favoritedWidgets).toHaveLength(0) + }) + + it('loads favorites from graph.extra on init, normalizing legacy nodeId entries', () => { + mockState.graph = { + extra: { + favoritedWidgets: { + favorites: [ + { nodeLocatorId: '1', widgetName: 'seed' }, + { nodeId: 2, widgetName: 'steps' }, + { widgetName: 'no-node' } + ] + } + } + } + registerNode(makeNode(1, [{ name: 'seed' }])) + registerNode(makeNode(2, [{ name: 'steps' }])) + + const store = useFavoritedWidgetsStore() + + expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([ + '1', + '2' + ]) + }) + + it('labels existing favorites when the graph is not loaded', () => { + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + const store = useFavoritedWidgetsStore() + store.addFavorite(node, 'seed') + + mockState.graph = null + + expect(store.favoritedWidgets[0].label).toContain('(graph not loaded)') + store.clearFavorites() + expect(store.favoritedWidgets).toHaveLength(0) + }) +}) diff --git a/src/stores/workspaceStore.test.ts b/src/stores/workspaceStore.test.ts new file mode 100644 index 00000000000..a7efe26667b --- /dev/null +++ b/src/stores/workspaceStore.test.ts @@ -0,0 +1,115 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useWorkspaceStore } from '@/stores/workspaceStore' + +const storeMocks = vi.hoisted(() => ({ + apiKeyAuthStore: { + isAuthenticated: false + }, + authStore: { + currentUser: null as null | { uid: string } + }, + commandStore: { + commands: [], + execute: vi.fn() + }, + executionErrorStore: { + lastExecutionError: null, + lastNodeErrors: null + }, + queueSettingsStore: {}, + settingStore: { + settingsById: {}, + get: vi.fn(), + set: vi.fn() + }, + sidebarTabStore: { + registerSidebarTab: vi.fn(), + unregisterSidebarTab: vi.fn(), + sidebarTabs: [] + }, + toastStore: {}, + workflowStore: {} +})) + +vi.mock('@vueuse/core', () => ({ + useMagicKeys: () => ({ shift: false }) +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => storeMocks.settingStore +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => storeMocks.toastStore +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => storeMocks.workflowStore +})) + +vi.mock('@/services/colorPaletteService', () => ({ + useColorPaletteService: () => ({}) +})) + +vi.mock('@/services/dialogService', () => ({ + useDialogService: () => ({}) +})) + +vi.mock('@/stores/apiKeyAuthStore', () => ({ + useApiKeyAuthStore: () => storeMocks.apiKeyAuthStore +})) + +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => storeMocks.authStore +})) + +vi.mock('@/stores/commandStore', () => ({ + useCommandStore: () => storeMocks.commandStore +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => storeMocks.executionErrorStore +})) + +vi.mock('@/stores/queueStore', () => ({ + useQueueSettingsStore: () => storeMocks.queueSettingsStore +})) + +vi.mock('@/stores/workspace/bottomPanelStore', () => ({ + useBottomPanelStore: () => ({}) +})) + +vi.mock('@/stores/workspace/sidebarTabStore', () => ({ + useSidebarTabStore: () => storeMocks.sidebarTabStore +})) + +describe('useWorkspaceStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + storeMocks.apiKeyAuthStore.isAuthenticated = false + storeMocks.authStore.currentUser = null + }) + + it('reports logged out when neither auth source is active', () => { + const store = useWorkspaceStore() + + expect(store.user.isLoggedIn).toBe(false) + }) + + it('reports logged in for API-key auth', () => { + storeMocks.apiKeyAuthStore.isAuthenticated = true + const store = useWorkspaceStore() + + expect(store.user.isLoggedIn).toBe(true) + }) + + it('reports logged in for Firebase auth', () => { + storeMocks.authStore.currentUser = { uid: 'user-1' } + const store = useWorkspaceStore() + + expect(store.user.isLoggedIn).toBe(true) + }) +}) diff --git a/src/utils/fuseUtil.test.ts b/src/utils/fuseUtil.test.ts new file mode 100644 index 00000000000..64144bb446d --- /dev/null +++ b/src/utils/fuseUtil.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { FuseSearchable } from '@/utils/fuseUtil' +import { FuseFilter, FuseSearch } from '@/utils/fuseUtil' + +interface SearchItem extends Partial { + name: string +} + +interface FilterItem { + options: string[] +} + +const makeSearch = (data: T[] = []) => + new FuseSearch(data, { + fuseOptions: { + keys: ['name'], + includeScore: true, + threshold: 0.6, + shouldSort: false + }, + advancedScoring: true + }) + +describe('FuseSearch', () => { + it('assigns stable ranking tiers for exact, prefix, word, substring, and multi-part matches', () => { + const search = new FuseSearch([], {}) + + const cases = [ + { query: 'load image', item: 'load image', tier: 0 }, + { query: 'load', item: 'Load Image', tier: 1 }, + { query: 'image', item: 'LoadImage', tier: 2 }, + { query: 'cast', item: 'broadcast', tier: 3 }, + { query: 'batch latent', item: 'LatentBatch', tier: 4 }, + { query: 'ten bat', item: 'LatentBatch', tier: 5 }, + { query: 'vae', item: 'KSampler', tier: 9 } + ] + + for (const { query, item, tier } of cases) { + expect(search.calcAuxSingle(query, item, 0)[0]).toBe(tier) + } + }) + + it('penalizes deprecated non-exact matches without penalizing exact matches', () => { + const search = makeSearch() + + expect( + search.calcAuxScores('image', { name: 'Image Deprecated' }, 0)[0] + ).toBe(6) + expect( + search.calcAuxScores('deprecated node', { name: 'Deprecated Node' }, 0)[0] + ).toBe(0) + }) + + it('lets searchable entries post-process their auxiliary scores', () => { + const search = makeSearch() + const entry: SearchItem = { + name: 'Image Loader', + postProcessSearchScores: (scores) => [scores[0] + 2, ...scores.slice(1)] + } + + expect(search.calcAuxScores('image', entry, 0)[0]).toBe(3) + }) + + it('sorts advanced search results by auxiliary ranking instead of Fuse order', () => { + const exact = { name: 'Image' } + const prefix = { name: 'Image Loader' } + const camelCaseWord = { name: 'LoadImage' } + const substring = { name: 'PreimageNode' } + const deprecated = { name: 'Image Deprecated' } + const search = makeSearch([ + substring, + deprecated, + camelCaseWord, + prefix, + exact + ]) + + expect(search.search('image')).toEqual([ + exact, + prefix, + camelCaseWord, + substring, + deprecated + ]) + }) + + it('returns data in original order for an empty query without calling Fuse', () => { + const data = [{ name: 'B' }, { name: 'A' }] + const search = makeSearch(data) + const fuseSearchSpy = vi.spyOn(search.fuse, 'search') + + expect(search.search('')).toEqual(data) + expect(fuseSearchSpy).not.toHaveBeenCalled() + }) + + it('compares auxiliary scores by the first differing value and then length', () => { + const search = new FuseSearch([], {}) + + expect( + [ + [1, 4], + [1, 2], + [0, 99] + ].sort(search.compareAux) + ).toEqual([ + [0, 99], + [1, 2], + [1, 4] + ]) + + expect( + [ + [1, 2, 0], + [1, 2] + ].sort(search.compareAux) + ).toEqual([ + [1, 2], + [1, 2, 0] + ]) + }) +}) + +describe('FuseFilter', () => { + it('matches single values, comma-separated values, and wildcard fallbacks', () => { + const imageItem = { options: ['IMAGE', 'LATENT'] } + const modelItem = { options: ['MODEL'] } + const filter = new FuseFilter([imageItem, modelItem], { + id: 'type', + name: 'Type', + invokeSequence: 't', + getItemOptions: (item) => item.options + }) + + expect(filter.getAllNodeOptions([imageItem, modelItem, imageItem])).toEqual( + ['IMAGE', 'LATENT', 'MODEL'] + ) + expect(filter.matches(imageItem, 'IMAGE')).toBe(true) + expect(filter.matches(imageItem, 'MODEL')).toBe(false) + expect(filter.matches(imageItem, 'MODEL,IMAGE')).toBe(true) + expect(filter.matches(modelItem, '*', { wildcard: '*' })).toBe(true) + expect(filter.matches(imageItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(true) + expect(filter.matches(modelItem, 'MODEL', { wildcard: 'IMAGE' })).toBe( + false + ) + }) +}) diff --git a/src/utils/gridUtil.test.ts b/src/utils/gridUtil.test.ts new file mode 100644 index 00000000000..ba06069485f --- /dev/null +++ b/src/utils/gridUtil.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createGridStyle } from '@/utils/gridUtil' + +describe('createGridStyle', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses auto-fill columns by default', () => { + expect(createGridStyle()).toEqual({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))', + padding: '0', + gap: '1rem' + }) + }) + + it('uses fixed columns when provided', () => { + expect( + createGridStyle({ + columns: 3, + padding: '8px', + gap: '4px' + }) + ).toEqual({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + padding: '8px', + gap: '4px' + }) + }) + + it('warns and clamps invalid fixed columns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(createGridStyle({ columns: -1 }).gridTemplateColumns).toBe( + 'repeat(1, 1fr)' + ) + expect(warn).toHaveBeenCalledWith( + 'createGridStyle: columns must be >= 1, defaulting to 1' + ) + }) + + it('warns for columns: 0 but falls through to auto-fill (falsy zero)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(createGridStyle({ columns: 0 }).gridTemplateColumns).toBe( + 'repeat(auto-fill, minmax(15rem, 1fr))' + ) + expect(warn).toHaveBeenCalledWith( + 'createGridStyle: columns must be >= 1, defaulting to 1' + ) + }) +}) diff --git a/src/utils/litegraphUtil.test.ts b/src/utils/litegraphUtil.test.ts index a0726c9252e..3628e264d03 100644 --- a/src/utils/litegraphUtil.test.ts +++ b/src/utils/litegraphUtil.test.ts @@ -1,14 +1,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { fromAny, fromPartial } from '@total-typescript/shoehorn' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation' +import type { + IBaseWidget, + IComboWidget +} from '@/lib/litegraph/src/types/widgets' import { toNodeId } from '@/types/nodeId' import { widgetId } from '@/types/widgetId' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' -import { createNode, getWidgetIdForNode, resolveNode } from './litegraphUtil' +import { + addToComboValues, + compressWidgetInputSlots, + createNode, + executeWidgetsCallback, + getItemsColorOption, + getLinkTypeColor, + getWidgetIdForNode, + isAnimatedOutput, + isAudioNode, + isImageNode, + isLoad3dNode, + isVideoNode, + isVideoOutput, + migrateWidgetsValues, + resolveComboValues, + resolveNode, + resolveNodeWidget +} from './litegraphUtil' const mockBringNodeToFront = vi.fn() @@ -191,3 +216,233 @@ describe('getWidgetIdForNode', () => { expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined() }) }) + +describe('media helpers', () => { + it('classifies preview media nodes', () => { + expect(isImageNode(undefined)).toBe(false) + expect(isVideoNode(undefined)).toBe(false) + expect(isAudioNode(undefined)).toBe(false) + + const imageNode = new LGraphNode('Image') + imageNode.previewMediaType = 'image' + const imageWithImgs = Object.assign(new LGraphNode('Image'), { + previewMediaType: 'model' as const, + imgs: [document.createElement('img')] + }) + const videoWithImgs = Object.assign(new LGraphNode('Video'), { + previewMediaType: 'video' as const, + imgs: [document.createElement('img')] + }) + const videoNode = new LGraphNode('Video') + videoNode.previewMediaType = 'video' + const videoContainerNode = Object.assign(new LGraphNode('Video'), { + videoContainer: document.body + }) + const audioNode = new LGraphNode('Audio') + audioNode.previewMediaType = 'audio' + + expect(isImageNode(imageNode)).toBe(true) + expect(isImageNode(imageWithImgs)).toBe(true) + expect(isImageNode(videoWithImgs)).toBe(false) + expect(isVideoNode(videoNode)).toBe(true) + expect(isVideoNode(videoContainerNode)).toBe(true) + expect(isAudioNode(audioNode)).toBe(true) + }) + + it('distinguishes animated images from video outputs', () => { + expect(isAnimatedOutput(undefined)).toBe(false) + expect(isAnimatedOutput({ animated: [false, true] })).toBe(true) + expect( + isVideoOutput({ + animated: [true], + images: [{ filename: 'clip.mp4' }] + }) + ).toBe(true) + expect( + isVideoOutput({ + animated: [true], + images: [{ filename: 'preview.webp' }] + }) + ).toBe(false) + expect( + isVideoOutput({ + animated: [true], + images: [{ filename: 'preview.png' }] + }) + ).toBe(false) + }) + + it('detects 3d loader nodes', () => { + const modelNode = new LGraphNode('Load3D') + modelNode.type = 'Load3D' + const animationNode = new LGraphNode('Load3DAnimation') + animationNode.type = 'Load3DAnimation' + const imageNode = new LGraphNode('LoadImage') + imageNode.type = 'LoadImage' + + expect(isLoad3dNode(modelNode)).toBe(true) + expect(isLoad3dNode(animationNode)).toBe(true) + expect(isLoad3dNode(imageNode)).toBe(false) + }) +}) + +describe('combo widget helpers', () => { + function combo(values: IComboWidget['options']['values']): IComboWidget { + return fromPartial({ + name: 'mode', + type: 'combo', + value: 'a', + options: { values } + }) + } + + it('resolves combo values from arrays, records, functions, and missing options', () => { + expect(resolveComboValues(combo(['a', 'b']))).toEqual(['a', 'b']) + expect(resolveComboValues(combo({ a: 'A', b: 'B' }))).toEqual(['a', 'b']) + expect(resolveComboValues(combo(() => ['x']))).toEqual(['x']) + expect( + resolveComboValues(fromPartial({ options: {} })) + ).toEqual([]) + }) + + it('adds only missing array combo values', () => { + const widget = combo(['a']) + + addToComboValues(widget, 'b') + addToComboValues(widget, 'b') + + expect(widget.options.values).toEqual(['a', 'b']) + }) +}) + +describe('node utility helpers', () => { + it('returns a shared color option only when all colorable items match', () => { + const red = { getColorOption: () => 'red', setColorOption: vi.fn() } + const redAgain = { getColorOption: () => 'red', setColorOption: vi.fn() } + const blue = { getColorOption: () => 'blue', setColorOption: vi.fn() } + + expect(getItemsColorOption([red, redAgain, {}])).toBe('red') + expect(getItemsColorOption([red, blue])).toBeNull() + expect(getItemsColorOption([{}])).toBeNull() + }) + + it('executes matching callbacks on node widgets', () => { + const onRemove = vi.fn() + const afterQueued = vi.fn() + const node = new LGraphNode('Callbacks') + node.widgets = [ + fromPartial({ onRemove }), + fromPartial({ afterQueued }) + ] + + executeWidgetsCallback([node], 'onRemove') + + expect(onRemove).toHaveBeenCalledOnce() + expect(afterQueued).not.toHaveBeenCalled() + }) + + it('returns configured link colors with the default fallback', () => { + expect(getLinkTypeColor('missing-type')).toBe(LiteGraph.LINK_COLOR) + }) +}) + +describe('legacy workflow migration helpers', () => { + it('drops legacy force-input widget values only when lengths match', () => { + const inputDefs = { + seed: { name: 'seed', type: 'INT', forceInput: true }, + mode: { name: 'mode', type: 'STRING' }, + batch: { + name: 'batch', + type: 'INT', + control_after_generate: true + } + } + const widgets = [ + fromPartial({ name: 'mode' }), + fromPartial({ name: 'batch' }) + ] + + expect(migrateWidgetsValues(inputDefs, widgets, [1, 2, 3, 4])).toEqual([ + 2, 3, 4 + ]) + expect(migrateWidgetsValues(inputDefs, widgets, [1, 2])).toEqual([1, 2]) + }) + + it('compresses root and subgraph widget input slots', () => { + const graph = fromPartial({ + nodes: [ + { + id: 1, + type: 'Node', + inputs: [ + { + name: 'widget', + type: 'STRING', + link: null, + widget: { name: 'w' } + }, + { name: 'kept', type: 'STRING', link: 7 } + ] + } + ], + links: [[7, 2, 0, 1, 99, 'STRING']], + definitions: { + subgraphs: [ + { + name: 'Subgraph', + nodes: [ + { + id: 3, + type: 'Inner', + inputs: [ + { + name: 'legacy', + type: 'STRING', + link: null, + widget: { name: 'legacy' } + }, + { name: 'inner', type: 'STRING', link: 8 } + ] + } + ], + links: [ + { + id: 8, + origin_id: 4, + origin_slot: 0, + target_id: 3, + target_slot: 42, + type: 'STRING' + } + ] + } + ] + } + }) + + compressWidgetInputSlots(graph) + + expect(graph.nodes[0].inputs?.map((input) => input.name)).toEqual(['kept']) + expect(graph.links[0][4]).toBe(0) + const subgraph = graph.definitions?.subgraphs?.[0] + expect(subgraph?.nodes?.[0].inputs?.map((input) => input.name)).toEqual([ + 'inner' + ]) + expect(subgraph?.links?.[0].target_slot).toBe(0) + }) +}) + +describe('resolveNodeWidget', () => { + it('resolves root graph nodes and widgets', () => { + setActivePinia(createTestingPinia({ stubActions: false })) + const graph = new LGraph() + const node = new LGraphNode('TestNode') + const widget = node.addWidget('text', 'prompt', 'hello', () => {}) + graph.add(node) + + expect(resolveNodeWidget(node.id, undefined, graph)).toEqual([node]) + expect(resolveNodeWidget(node.id, 'prompt', graph)).toEqual([node, widget]) + expect(resolveNodeWidget(node.id, 'missing', graph)).toEqual([]) + expect(resolveNodeWidget('not-a-node-id', 'prompt', graph)).toEqual([]) + }) +}) diff --git a/src/utils/mapperUtil.test.ts b/src/utils/mapperUtil.test.ts new file mode 100644 index 00000000000..0af7b927ed5 --- /dev/null +++ b/src/utils/mapperUtil.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import type { components } from '@/types/comfyRegistryTypes' +import { registryToFrontendV2NodeDef } from '@/utils/mapperUtil' + +type RegistryNode = components['schemas']['ComfyNode'] +type RegistryPack = components['schemas']['Node'] + +function nodeDef(over: Partial = {}): RegistryNode { + return over as RegistryNode +} + +function pack(over: Partial = {}): RegistryPack { + return over as RegistryPack +} + +describe('registryToFrontendV2NodeDef', () => { + it('maps outputs, defaulting names to types and is_list to false', () => { + const def = registryToFrontendV2NodeDef( + nodeDef({ + return_types: '["INT","IMAGE"]', + return_names: '["count",""]', + output_is_list: [true] + }), + pack() + ) + + expect(def.outputs).toEqual([ + { type: 'INT', name: 'count', is_list: true, index: 0 }, + { type: 'IMAGE', name: 'IMAGE', is_list: false, index: 1 } + ]) + }) + + it('returns no outputs when return_types is empty or absent', () => { + expect( + registryToFrontendV2NodeDef(nodeDef({ return_types: '[]' }), pack()) + .outputs + ).toEqual([]) + expect(registryToFrontendV2NodeDef(nodeDef(), pack()).outputs).toEqual([]) + }) + + it('maps required and optional inputs into keyed specs', () => { + const def = registryToFrontendV2NodeDef( + nodeDef({ + input_types: JSON.stringify({ + required: { seed: ['INT', { default: 0 }] }, + optional: { label: ['STRING', {}] } + }) + }), + pack() + ) + + expect(def.inputs).toEqual({ + seed: { type: 'INT', name: 'seed', isOptional: false, default: 0 }, + label: { type: 'STRING', name: 'label', isOptional: true } + }) + }) + + it('returns no inputs when input_types is empty or absent', () => { + expect(registryToFrontendV2NodeDef(nodeDef(), pack()).inputs).toEqual({}) + expect( + registryToFrontendV2NodeDef(nodeDef({ input_types: '{}' }), pack()).inputs + ).toEqual({}) + }) + + it('applies field fallbacks for name, category, and python_module', () => { + const def = registryToFrontendV2NodeDef(nodeDef(), pack({ id: 'pack-id' })) + + expect(def.name).toBe('Node Name') + expect(def.display_name).toBe('Node Name') + expect(def.category).toBe('unknown') + expect(def.python_module).toBe('pack-id') // name absent -> falls back to id + }) + + it('prefers explicit values over fallbacks', () => { + const def = registryToFrontendV2NodeDef( + nodeDef({ comfy_node_name: 'KSampler', category: 'sampling' }), + pack({ name: 'comfy-core' }) + ) + + expect(def.name).toBe('KSampler') + expect(def.category).toBe('sampling') + expect(def.python_module).toBe('comfy-core') + }) +}) diff --git a/src/utils/mouseDownUtil.test.ts b/src/utils/mouseDownUtil.test.ts new file mode 100644 index 00000000000..763d0e2a53e --- /dev/null +++ b/src/utils/mouseDownUtil.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { whileMouseDown } from '@/utils/mouseDownUtil' + +describe('whileMouseDown', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs until the element receives mouseup', () => { + const element = document.createElement('button') + const callback = vi.fn() + + whileMouseDown(element, callback, 10) + vi.advanceTimersByTime(25) + element.dispatchEvent(new MouseEvent('mouseup')) + vi.advanceTimersByTime(30) + + expect(callback.mock.calls).toEqual([[0], [1]]) + }) + + it('uses the event target and stops on document mouseup', () => { + const element = document.createElement('button') + const event = new MouseEvent('mousedown') + Object.defineProperty(event, 'target', { value: element }) + const callback = vi.fn() + + whileMouseDown(event, callback, 5) + vi.advanceTimersByTime(12) + document.dispatchEvent(new MouseEvent('mouseup')) + vi.advanceTimersByTime(20) + + expect(callback.mock.calls).toEqual([[0], [1]]) + }) +}) diff --git a/src/utils/nodeTitleUtil.test.ts b/src/utils/nodeTitleUtil.test.ts new file mode 100644 index 00000000000..c55b31c6ce7 --- /dev/null +++ b/src/utils/nodeTitleUtil.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { normalizeI18nKey } from '@/utils/formatUtil' +import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil' + +const options = { + emptyLabel: 'Empty Node', + untitledLabel: 'Untitled Node', + st: vi.fn((key: string, fallback: string) => `${key}:${fallback}`) +} + +describe('resolveNodeDisplayName', () => { + beforeEach(() => { + options.st.mockClear() + }) + + it('uses the empty label when no node is available', () => { + expect(resolveNodeDisplayName(null, options)).toBe('Empty Node') + expect(resolveNodeDisplayName(undefined, options)).toBe('Empty Node') + expect(options.st).not.toHaveBeenCalled() + }) + + it('prefers a trimmed explicit title', () => { + expect( + resolveNodeDisplayName( + { title: ' KSampler ', type: 'Ignored' }, + options + ) + ).toBe('KSampler') + expect(options.st).not.toHaveBeenCalled() + }) + + it('translates the node type when the title is empty', () => { + const result = resolveNodeDisplayName( + { title: '', type: 'CLIP Text Encode' }, + options + ) + const expectedKey = `nodeDefs.${normalizeI18nKey('CLIP Text Encode')}.display_name` + expect(options.st).toHaveBeenCalledWith(expectedKey, 'CLIP Text Encode') + expect(result).toBe(`${expectedKey}:CLIP Text Encode`) + }) + + it('falls back to the untitled label when title and type are empty', () => { + const expectedKey = `nodeDefs.${normalizeI18nKey('Untitled Node')}.display_name` + expect(resolveNodeDisplayName({ title: '', type: '' }, options)).toBe( + `${expectedKey}:Untitled Node` + ) + expect(resolveNodeDisplayName({}, options)).toBe( + `${expectedKey}:Untitled Node` + ) + }) +}) diff --git a/src/utils/objectUrlUtil.test.ts b/src/utils/objectUrlUtil.test.ts new file mode 100644 index 00000000000..31d2385cee0 --- /dev/null +++ b/src/utils/objectUrlUtil.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createSharedObjectUrl, + releaseSharedObjectUrl, + retainSharedObjectUrl +} from './objectUrlUtil' + +describe('objectUrlUtil', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('retains and releases shared blob URLs by reference count', () => { + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL') + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test') + + const url = createSharedObjectUrl(new Blob(['data'])) + retainSharedObjectUrl(url) + releaseSharedObjectUrl(url) + + expect(revokeObjectURL).not.toHaveBeenCalled() + + releaseSharedObjectUrl(url) + + expect(revokeObjectURL).toHaveBeenCalledWith(url) + }) + + it('ignores missing and non-blob URLs', () => { + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL') + + retainSharedObjectUrl(undefined) + retainSharedObjectUrl('https://example.com/image.png') + releaseSharedObjectUrl(undefined) + releaseSharedObjectUrl('https://example.com/image.png') + + expect(revokeObjectURL).not.toHaveBeenCalled() + }) + + it('revokes unknown blob URLs once', () => { + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL') + + releaseSharedObjectUrl('blob:unknown') + + expect(revokeObjectURL).toHaveBeenCalledOnce() + expect(revokeObjectURL).toHaveBeenCalledWith('blob:unknown') + }) +}) diff --git a/src/utils/queueDisplay.test.ts b/src/utils/queueDisplay.test.ts new file mode 100644 index 00000000000..1722ce87bcd --- /dev/null +++ b/src/utils/queueDisplay.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' + +import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' +import type { JobState } from '@/types/queue' +import type { BuildJobDisplayCtx } from '@/utils/queueDisplay' +import { buildJobDisplay, iconForJobState } from '@/utils/queueDisplay' + +type QueueDisplayTask = Parameters[0] +type PreviewOutput = NonNullable + +function createJob( + status: JobListItem['status'], + overrides: Partial = {} +): JobListItem { + return { + id: 'job-123456', + status, + create_time: 1_710_000_000_000, + priority: 12, + ...overrides + } +} + +function createTask( + options: { + job?: Partial + jobId?: string + createTime?: number | undefined + executionTime?: number + executionTimeInSeconds?: number + previewOutput?: PreviewOutput + } = {} +): QueueDisplayTask { + const { + job, + jobId = 'job-123456', + executionTime, + executionTimeInSeconds, + previewOutput + } = options + const createTime = Object.hasOwn(options, 'createTime') + ? options.createTime + : 1_710_000_000_000 + + return { + job: createJob(job?.status ?? 'pending', job), + jobId, + createTime, + executionTime, + executionTimeInSeconds, + previewOutput + } as QueueDisplayTask +} + +function createCtx( + overrides: Partial = {} +): BuildJobDisplayCtx { + return { + t: (key, values) => { + const entries = Object.entries(values ?? {}) + if (!entries.length) return key + + return `${key}(${entries + .map(([name, value]) => `${name}=${String(value)}`) + .join(',')})` + }, + locale: 'en-US', + formatClockTimeFn: (ts, locale) => `${locale}:${ts}`, + isActive: false, + ...overrides + } +} + +describe('iconForJobState', () => { + it.for<[JobState, string]>([ + ['pending', 'icon-[lucide--loader-circle]'], + ['initialization', 'icon-[lucide--server-crash]'], + ['running', 'icon-[lucide--zap]'], + ['completed', 'icon-[lucide--check-check]'], + ['failed', 'icon-[lucide--alert-circle]'] + ])('maps %s to its icon', ([state, icon]) => { + expect(iconForJobState(state)).toBe(icon) + }) + + it('uses a neutral icon for unrecognized states', () => { + expect(iconForJobState('archived' as JobState)).toBe( + 'icon-[lucide--circle]' + ) + }) +}) + +describe('buildJobDisplay', () => { + it('shows the added hint for pending jobs when requested', () => { + expect( + buildJobDisplay( + createTask(), + 'pending', + createCtx({ showAddedHint: true }) + ) + ).toEqual({ + iconName: 'icon-[lucide--check]', + primary: 'queue.jobAddedToQueue', + secondary: 'en-US:1710000000000', + showClear: true + }) + }) + + it('shows queued time for pending and initializing jobs', () => { + expect(buildJobDisplay(createTask(), 'pending', createCtx())).toMatchObject( + { + iconName: 'icon-[lucide--loader-circle]', + primary: 'queue.inQueue', + secondary: 'en-US:1710000000000', + showClear: true + } + ) + + expect( + buildJobDisplay(createTask(), 'initialization', createCtx()) + ).toMatchObject({ + iconName: 'icon-[lucide--server-crash]', + primary: 'queue.initializingAlmostReady', + secondary: 'en-US:1710000000000', + showClear: true + }) + }) + + it('formats active running progress from the injected context', () => { + expect( + buildJobDisplay( + createTask({ job: { status: 'in_progress' } }), + 'running', + createCtx({ + isActive: true, + totalPercent: 42.7, + currentNodePercent: -10, + currentNodeName: 'KSampler' + }) + ) + ).toEqual({ + iconName: 'icon-[lucide--zap]', + primary: 'sideToolbar.queueProgressOverlay.total(percent=43%)', + secondary: + 'KSampler sideToolbar.queueProgressOverlay.colonPercent(percent=0%)', + showClear: true + }) + }) + + it('omits current node progress when the active job has no node name', () => { + expect( + buildJobDisplay( + createTask({ job: { status: 'in_progress' } }), + 'running', + createCtx({ + isActive: true, + totalPercent: 101, + currentNodePercent: 50 + }) + ) + ).toMatchObject({ + primary: 'sideToolbar.queueProgressOverlay.total(percent=100%)', + secondary: '' + }) + }) + + it('uses a compact running label when the job is not active', () => { + expect( + buildJobDisplay( + createTask({ job: { status: 'in_progress' } }), + 'running', + createCtx() + ) + ).toEqual({ + iconName: 'icon-[lucide--zap]', + primary: 'g.running', + secondary: '', + showClear: true + }) + }) + + it('shows local completed jobs as the preview filename', () => { + expect( + buildJobDisplay( + createTask({ + job: { + status: 'completed' + }, + executionTimeInSeconds: 3.51, + previewOutput: { + filename: 'preview.png', + isImage: true, + url: '/api/view?filename=preview.png&type=output&subfolder=' + } as PreviewOutput + }), + 'completed', + createCtx() + ) + ).toEqual({ + iconName: 'icon-[lucide--check-check]', + iconImageUrl: '/api/view?filename=preview.png&type=output&subfolder=', + primary: 'preview.png', + secondary: '3.51s', + showClear: false + }) + }) + + it('shows cloud completed jobs as elapsed time', () => { + expect( + buildJobDisplay( + createTask({ + job: { + status: 'completed' + }, + executionTime: 64_000, + executionTimeInSeconds: 64 + }), + 'completed', + createCtx({ isCloud: true }) + ) + ).toMatchObject({ + iconName: 'icon-[lucide--check-check]', + primary: 'queue.completedIn(duration=1m 4s)', + secondary: '64.00s', + showClear: false + }) + }) + + it('falls back to job title for completed jobs without a preview filename', () => { + expect( + buildJobDisplay( + createTask({ + job: { + status: 'completed', + priority: 42 + } + }), + 'completed', + createCtx() + ) + ).toMatchObject({ + iconName: 'icon-[lucide--check-check]', + primary: 'g.job #42', + secondary: '', + showClear: false + }) + }) + + it('builds completed fallback titles from job id or the generic label', () => { + expect( + buildJobDisplay( + createTask({ + jobId: 'abcdef-123', + job: { status: 'completed', priority: undefined } + }), + 'completed', + createCtx() + ).primary + ).toBe('g.job abcdef') + + expect( + buildJobDisplay( + createTask({ + jobId: '', + job: { status: 'completed', id: '', priority: undefined } + }), + 'completed', + createCtx() + ).primary + ).toBe('g.job') + }) + + it('uses an empty queued timestamp when create time is unavailable', () => { + expect( + buildJobDisplay( + createTask({ createTime: undefined }), + 'pending', + createCtx() + ).secondary + ).toBe('') + }) + + it('shows failed jobs as clearable failures', () => { + expect(buildJobDisplay(createTask(), 'failed', createCtx())).toEqual({ + iconName: 'icon-[lucide--alert-circle]', + primary: 'g.failed', + secondary: 'g.failed', + showClear: true + }) + }) + + it('falls back to a neutral clearable display for unrecognized states', () => { + expect( + buildJobDisplay( + createTask({ jobId: 'abcdef-123' }), + 'archived' as JobState, + createCtx() + ) + ).toEqual({ + iconName: 'icon-[lucide--circle]', + primary: 'g.job #12', + secondary: '', + showClear: true + }) + }) +}) diff --git a/src/utils/rafBatch.test.ts b/src/utils/rafBatch.test.ts new file mode 100644 index 00000000000..3eef1f5260c --- /dev/null +++ b/src/utils/rafBatch.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createRafBatch } from './rafBatch' + +describe('createRafBatch', () => { + const callbacks = new Map() + const cancelAnimationFrame = vi.fn() + + beforeEach(() => { + callbacks.clear() + cancelAnimationFrame.mockClear() + let nextId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = ++nextId + callbacks.set(id, callback) + return id + }) + ) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('coalesces scheduled work into one animation frame', () => { + const run = vi.fn() + const batch = createRafBatch(run) + + batch.schedule() + batch.schedule() + + expect(requestAnimationFrame).toHaveBeenCalledOnce() + expect(batch.isScheduled()).toBe(true) + + callbacks.get(1)?.(0) + + expect(run).toHaveBeenCalledOnce() + expect(batch.isScheduled()).toBe(false) + }) + + it('cancels and flushes scheduled work', () => { + const run = vi.fn() + const batch = createRafBatch(run) + + batch.cancel() + batch.flush() + + expect(cancelAnimationFrame).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + + batch.schedule() + batch.cancel() + + expect(cancelAnimationFrame).toHaveBeenCalledWith(1) + expect(batch.isScheduled()).toBe(false) + + batch.schedule() + batch.flush() + + expect(cancelAnimationFrame).toHaveBeenCalledWith(2) + expect(run).toHaveBeenCalledOnce() + expect(batch.isScheduled()).toBe(false) + }) +}) diff --git a/src/utils/treeUtil.test.ts b/src/utils/treeUtil.test.ts index ebd1ffbd5ad..56817a5051a 100644 --- a/src/utils/treeUtil.test.ts +++ b/src/utils/treeUtil.test.ts @@ -1,7 +1,21 @@ import { describe, expect, it } from 'vitest' import type { TreeNode } from '@/types/treeExplorerTypes' -import { buildTree, sortedTree } from '@/utils/treeUtil' +import { + buildTree, + combineTrees, + findNodeByKey, + flattenTree, + sortedTree, + unwrapTreeRoot +} from '@/utils/treeUtil' + +const createNode = (label: string, leaf = false): TreeNode => ({ + key: label, + label, + leaf, + children: [] +}) describe('buildTree', () => { it('should handle empty folder items correctly', () => { @@ -65,14 +79,101 @@ describe('buildTree', () => { }) }) -describe('sortedTree', () => { - const createNode = (label: string, leaf = false): TreeNode => ({ - key: label, - label, - leaf, - children: [] +describe('unwrapTreeRoot', () => { + it('promotes the single non-leaf folder child', () => { + const tree: TreeNode = { + key: 'root', + label: 'root', + children: [ + { + key: 'root/a', + label: 'a', + leaf: false, + children: [createNode('child', true)] + } + ] + } + + expect(unwrapTreeRoot(tree).children?.map((node) => node.key)).toEqual([ + 'child' + ]) + }) + + it('keeps roots with leaf, empty, or multiple children intact', () => { + const leafRoot: TreeNode = { + key: 'root', + label: 'root', + children: [createNode('leaf', true)] + } + const emptyFolderRoot: TreeNode = { + key: 'root', + label: 'root', + children: [createNode('folder')] + } + const multiRoot: TreeNode = { + key: 'root', + label: 'root', + children: [createNode('a'), createNode('b')] + } + const childWithoutChildren: TreeNode = { + key: 'root', + label: 'root', + children: [ + { + key: 'root/a', + label: 'a', + leaf: false + } + ] + } + + expect(unwrapTreeRoot(leafRoot)).toBe(leafRoot) + expect(unwrapTreeRoot(emptyFolderRoot)).toBe(emptyFolderRoot) + expect(unwrapTreeRoot(multiRoot)).toBe(multiRoot) + expect(unwrapTreeRoot(childWithoutChildren)).toBe(childWithoutChildren) + }) +}) + +describe('flattenTree', () => { + it('returns data from leaf nodes only', () => { + const tree: TreeNode = { + key: 'root', + label: 'root', + children: [ + { + key: 'folder', + label: 'folder', + children: [ + { + key: 'leaf-a', + label: 'leaf-a', + leaf: true, + data: { path: 'a' } + }, + { + key: 'leaf-b', + label: 'leaf-b', + leaf: true + } + ] + }, + { + key: 'leaf-c', + label: 'leaf-c', + leaf: true, + data: { path: 'c' } + } + ] + } + + expect(flattenTree<{ path: string }>(tree)).toEqual([ + { path: 'c' }, + { path: 'a' } + ]) }) +}) +describe('sortedTree', () => { it('should return a new node instance', () => { const node = createNode('root') const result = sortedTree(node) @@ -92,6 +193,25 @@ describe('sortedTree', () => { expect(result.children?.map((c) => c.label)).toEqual(['a', 'b', 'c']) }) + it('sorts children with missing labels by the empty-label fallback', () => { + const unlabeled = { + key: 'missing', + label: undefined as unknown as string, + leaf: true + } + const node: TreeNode = { + key: 'root', + label: 'root', + leaf: false, + children: [unlabeled, createNode('a', true)] + } + + expect(sortedTree(node).children?.map((c) => c.key)).toEqual([ + 'missing', + 'a' + ]) + }) + describe('with groupLeaf=true', () => { it('should group folders before files', () => { const node: TreeNode = { @@ -110,6 +230,35 @@ describe('sortedTree', () => { expect(labels).toEqual(['folder1', 'folder2', 'another.txt', 'file.txt']) }) + it('sorts grouped children with missing labels', () => { + const unlabeledFolder = { + key: 'folder-missing', + label: undefined as unknown as string, + leaf: false, + children: [] + } + const unlabeledFile = { + key: 'file-missing', + label: undefined as unknown as string, + leaf: true, + children: [] + } + const node: TreeNode = { + key: 'root', + label: 'root', + children: [ + createNode('folder-b'), + unlabeledFolder, + createNode('file-b', true), + unlabeledFile + ] + } + + expect( + sortedTree(node, { groupLeaf: true }).children?.map((c) => c.key) + ).toEqual(['folder-missing', 'folder-b', 'file-missing', 'file-b']) + }) + it('should sort recursively', () => { const node: TreeNode = { key: 'root', @@ -145,3 +294,54 @@ describe('sortedTree', () => { expect(result).toEqual(node) }) }) + +describe('findNodeByKey', () => { + it('returns the matching nested node or null', () => { + const child = createNode('root/child') + const tree: TreeNode = { + key: 'root', + label: 'root', + children: [child] + } + + expect(findNodeByKey(tree, 'root')).toBe(tree) + expect(findNodeByKey(tree, 'root/child')).toBe(child) + expect(findNodeByKey(tree, 'missing')).toBeNull() + expect(findNodeByKey(createNode('root'), 'missing')).toBeNull() + }) +}) + +describe('combineTrees', () => { + it('adds a cloned subtree under its matching parent', () => { + const root: TreeNode = { + key: 'root', + label: 'root', + children: [{ key: 'root/a', label: 'a', children: [] }] + } + const subtree: TreeNode = { + key: 'root/a/b', + label: 'b', + leaf: true, + data: { path: 'b' } + } + + const combined = combineTrees(root, subtree) + + expect(combined).not.toBe(root) + expect(combined.children?.[0].children?.[0]).toEqual(subtree) + expect(combined.children?.[0].children?.[0]).not.toBe(subtree) + expect(root.children?.[0].children).toEqual([]) + }) + + it('returns a clone unchanged when the parent key is absent', () => { + const root: TreeNode = { key: 'root', label: 'root' } + const combined = combineTrees(root, { + key: 'root/missing/leaf', + label: 'leaf', + leaf: true + }) + + expect(combined).toEqual(root) + expect(combined).not.toBe(root) + }) +}) diff --git a/src/utils/treeUtil.ts b/src/utils/treeUtil.ts index 373281ad0f4..bd0d3e05358 100644 --- a/src/utils/treeUtil.ts +++ b/src/utils/treeUtil.ts @@ -148,7 +148,7 @@ function cloneTree(node: T): T { const clone = { ...node } // Clone children recursively - if (node.children && node.children.length > 0) { + if (node.children) { clone.children = node.children.map((child) => cloneTree(child)) } diff --git a/src/utils/typeGuardUtil.test.ts b/src/utils/typeGuardUtil.test.ts index 9a58655b2db..6b4110a4312 100644 --- a/src/utils/typeGuardUtil.test.ts +++ b/src/utils/typeGuardUtil.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { isSubgraphIoNode } from '@/utils/typeGuardUtil' +import { + isAbortError, + isNonNullish, + isResultItemType, + isSlotObject, + isSubgraph, + isSubgraphIoNode +} from '@/utils/typeGuardUtil' type NodeConstructor = { comfyClass?: string } @@ -10,6 +17,40 @@ function createMockNode(nodeConstructor?: NodeConstructor): LGraphNode { } describe('typeGuardUtil', () => { + describe('isAbortError', () => { + it('matches AbortError DOMExceptions only', () => { + expect(isAbortError(new DOMException('cancelled', 'AbortError'))).toBe( + true + ) + expect(isAbortError(new DOMException('failed', 'NetworkError'))).toBe( + false + ) + expect(isAbortError({ name: 'AbortError' })).toBe(false) + }) + }) + + describe('isSubgraph', () => { + it('matches non-root graphs only', () => { + const subgraph = { + isRootGraph: false + } as Parameters[0] + const rootGraph = { + isRootGraph: true + } as Parameters[0] + + expect(isSubgraph(subgraph)).toBe(true) + expect(isSubgraph(rootGraph)).toBe(false) + expect(isSubgraph(null)).toBe(false) + }) + }) + + describe('isNonNullish', () => { + it('filters nullish values without dropping falsy data', () => { + const values = [0, '', null, undefined, false, 'ok'] + expect(values.filter(isNonNullish)).toEqual([0, '', false, 'ok']) + }) + }) + describe('isSubgraphIoNode', () => { it('should identify SubgraphInputNode as IO node', () => { const node = createMockNode({ comfyClass: 'SubgraphInputNode' }) @@ -41,4 +82,22 @@ describe('typeGuardUtil', () => { expect(isSubgraphIoNode(node)).toBe(false) }) }) + + describe('isSlotObject', () => { + it('requires the slot shape fields', () => { + expect( + isSlotObject({ name: 'image', type: 'IMAGE', boundingRect: [] }) + ).toBe(true) + expect(isSlotObject(null)).toBe(false) + expect(isSlotObject({ name: 'image', type: 'IMAGE' })).toBe(false) + }) + }) + + describe('isResultItemType', () => { + it('recognizes backend result buckets', () => { + expect(['input', 'output', 'temp'].every(isResultItemType)).toBe(true) + expect(isResultItemType('cache')).toBe(false) + expect(isResultItemType(undefined)).toBe(false) + }) + }) }) diff --git a/src/workbench/extensions/manager/composables/nodePack/usePackInstall.test.ts b/src/workbench/extensions/manager/composables/nodePack/usePackInstall.test.ts new file mode 100644 index 00000000000..ad3609f50a1 --- /dev/null +++ b/src/workbench/extensions/manager/composables/nodePack/usePackInstall.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { components } from '@/types/comfyRegistryTypes' +import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall' +import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes' + +type NodePack = components['schemas']['Node'] +type CompatibilityCheck = { + hasConflict: boolean + conflicts: ConflictDetail[] +} + +const { managerStore, showDialog, checkNodeCompatibility } = vi.hoisted(() => ({ + managerStore: { + installPack: { call: vi.fn(), clear: vi.fn() }, + isPackInstalling: vi.fn((_id?: string) => false), + isPackInstalled: vi.fn((_id?: string) => false) + }, + showDialog: vi.fn(), + checkNodeCompatibility: vi.fn( + (): CompatibilityCheck => ({ hasConflict: false, conflicts: [] }) + ) +})) + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + +vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({ + useComfyManagerStore: () => managerStore +})) + +vi.mock( + '@/workbench/extensions/manager/composables/useNodeConflictDialog', + () => ({ + useNodeConflictDialog: () => ({ show: showDialog }) + }) +) + +vi.mock( + '@/workbench/extensions/manager/composables/useConflictDetection', + () => ({ + useConflictDetection: () => ({ checkNodeCompatibility }) + }) +) + +function pack(over: Partial = {}): NodePack { + return { id: 'pack-a', name: 'Pack A', ...over } as NodePack +} + +function conflict(overrides: Partial = {}): ConflictDetail { + return { + type: 'os', + current_value: 'linux', + required_value: 'darwin', + ...overrides + } +} + +beforeEach(() => { + managerStore.installPack.call.mockReset().mockResolvedValue(undefined) + managerStore.installPack.clear.mockReset() + managerStore.isPackInstalling.mockReset().mockReturnValue(false) + managerStore.isPackInstalled.mockReset().mockReturnValue(false) + showDialog.mockReset() + checkNodeCompatibility.mockReset().mockReturnValue({ + hasConflict: false, + conflicts: [] + }) +}) + +describe('usePackInstall', () => { + it('reports isInstalling when any pack is installing', () => { + managerStore.isPackInstalling.mockImplementation( + (id?: string) => id === 'pack-b' + ) + const { isInstalling } = usePackInstall(() => [ + pack(), + pack({ id: 'pack-b' }) + ]) + expect(isInstalling.value).toBe(true) + }) + + it('reports not installing for an empty or idle pack list', () => { + expect(usePackInstall(() => []).isInstalling.value).toBe(false) + expect( + usePackInstall(() => undefined as unknown as NodePack[]).isInstalling + .value + ).toBe(false) + expect(usePackInstall(() => [pack()]).isInstalling.value).toBe(false) + }) + + it('installs each pack and clears the command afterward', async () => { + const { performInstallation } = usePackInstall(() => []) + await performInstallation([ + pack({ + id: 'a', + latest_version: { version: '1.2.0' } + } as Partial), + pack({ id: 'b', publisher: { name: 'Unclaimed' } } as Partial) + ]) + + expect(managerStore.installPack.call).toHaveBeenCalledTimes(2) + expect(managerStore.installPack.call).toHaveBeenCalledWith( + expect.objectContaining({ id: 'a', selected_version: '1.2.0' }) + ) + expect(managerStore.installPack.call).toHaveBeenCalledWith( + expect.objectContaining({ id: 'b', selected_version: 'nightly' }) + ) + expect(managerStore.installPack.clear).toHaveBeenCalled() + }) + + it('installAllPacks installs only the not-yet-installed packs', async () => { + managerStore.isPackInstalled.mockImplementation( + (id?: string) => id === 'installed' + ) + const { installAllPacks } = usePackInstall(() => [ + pack({ id: 'installed' }), + pack({ id: 'fresh' }) + ]) + + await installAllPacks() + + expect(managerStore.installPack.call).toHaveBeenCalledTimes(1) + expect(managerStore.installPack.call).toHaveBeenCalledWith( + expect.objectContaining({ id: 'fresh' }) + ) + }) + + it('installAllPacks returns early for empty or already installed packs', async () => { + await usePackInstall(() => []).installAllPacks() + + managerStore.isPackInstalled.mockReturnValue(true) + await usePackInstall(() => [pack({ id: 'installed' })]).installAllPacks() + + expect(managerStore.installPack.call).not.toHaveBeenCalled() + expect(managerStore.installPack.clear).not.toHaveBeenCalled() + }) + + it('installAllPacks opens the conflict dialog instead of installing when conflicted', async () => { + const osConflict = conflict() + checkNodeCompatibility.mockReturnValue({ + hasConflict: true, + conflicts: [osConflict] + }) + const { installAllPacks } = usePackInstall( + () => [pack({ id: 'x' })], + () => true, + () => [osConflict] + ) + + await installAllPacks() + + expect(showDialog).toHaveBeenCalledTimes(1) + expect(showDialog).toHaveBeenCalledWith( + expect.objectContaining({ + conflictedPackages: [ + expect.objectContaining({ + package_id: 'x', + package_name: 'Pack A', + has_conflict: true, + conflicts: [osConflict], + is_compatible: false + }) + ] + }) + ) + expect(managerStore.installPack.call).not.toHaveBeenCalled() + }) + + it('installAllPacks stops when conflict details are unavailable', async () => { + const { installAllPacks } = usePackInstall( + () => [pack({ id: 'x' })], + () => true + ) + + await installAllPacks() + + expect(showDialog).not.toHaveBeenCalled() + expect(managerStore.installPack.call).not.toHaveBeenCalled() + }) + + it('conflict dialog payload falls back for unnamed package data', async () => { + checkNodeCompatibility.mockReturnValue({ + hasConflict: true, + conflicts: [conflict()] + }) + const { installAllPacks } = usePackInstall( + () => [pack({ id: undefined, name: undefined })], + () => true, + () => [conflict()] + ) + + await installAllPacks() + + expect(showDialog).toHaveBeenCalledWith( + expect.objectContaining({ + conflictedPackages: [ + expect.objectContaining({ + package_id: '', + package_name: '' + }) + ] + }) + ) + }) + + it('conflict dialog action installs only packs still missing', async () => { + checkNodeCompatibility.mockReturnValue({ + hasConflict: false, + conflicts: [] + }) + managerStore.isPackInstalled.mockImplementation( + (id?: string) => id === 'installed' + ) + const { installAllPacks } = usePackInstall( + () => [pack({ id: 'installed' }), pack({ id: 'fresh' })], + () => true, + () => [conflict()] + ) + + await installAllPacks() + const [{ onButtonClick }] = showDialog.mock.calls[0] + await onButtonClick() + + expect(managerStore.installPack.call).toHaveBeenCalledTimes(1) + expect(managerStore.installPack.call).toHaveBeenCalledWith( + expect.objectContaining({ id: 'fresh' }) + ) + expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1) + }) + + it('conflict dialog action returns when every pack is already installed', async () => { + managerStore.isPackInstalled.mockReturnValue(true) + const { installAllPacks } = usePackInstall( + () => [pack({ id: 'installed' })], + () => true, + () => [conflict()] + ) + + await installAllPacks() + const [{ onButtonClick }] = showDialog.mock.calls[0] + await onButtonClick() + + expect(managerStore.installPack.call).not.toHaveBeenCalled() + expect(managerStore.installPack.clear).not.toHaveBeenCalled() + }) + + it('clears the command when payload validation rejects', async () => { + const { performInstallation } = usePackInstall(() => []) + + await expect( + performInstallation([pack({ id: undefined })]) + ).rejects.toThrow('manager.packInstall.nodeIdRequired') + + expect(managerStore.installPack.call).not.toHaveBeenCalled() + expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1) + }) + + it('leaves command cleanup in finally when one install fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + managerStore.installPack.call + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('failed')) + const { performInstallation } = usePackInstall(() => []) + + await performInstallation([pack({ id: 'a' }), pack({ id: 'b' })]) + + expect(consoleError).toHaveBeenCalledWith( + '[usePackInstall] Some installations failed:', + [expect.any(Error)] + ) + expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1) + } finally { + consoleError.mockRestore() + } + }) +}) diff --git a/src/workbench/extensions/manager/composables/useManagerDisplayPacks.test.ts b/src/workbench/extensions/manager/composables/useManagerDisplayPacks.test.ts new file mode 100644 index 00000000000..fa6640dfc19 --- /dev/null +++ b/src/workbench/extensions/manager/composables/useManagerDisplayPacks.test.ts @@ -0,0 +1,279 @@ +import type * as VueUse from '@vueuse/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { components } from '@/types/comfyRegistryTypes' +import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' +import { useManagerDisplayPacks } from '@/workbench/extensions/manager/composables/useManagerDisplayPacks' + +type NodePack = components['schemas']['Node'] + +const { state } = vi.hoisted(() => ({ + state: { + installed: [] as NodePack[], + workflow: [] as NodePack[], + installedLoading: false, + workflowLoading: false, + installedReady: true, + workflowReady: true, + startFetchInstalled: vi.fn(), + startFetchWorkflowPacks: vi.fn(), + installedIds: new Set(), + installedVersions: {} as Record, + conflicts: [] as { package_id: string }[] + } +})) + +vi.mock('@vueuse/core', async (orig) => ({ + ...(await orig()), + whenever: (source: unknown, callback?: () => void) => { + if (typeof source === 'function' && source() && callback) { + callback() + } + } +})) + +vi.mock('@/services/gateway/registrySearchGateway', () => ({ + useRegistrySearchGateway: () => ({ + getSortValue: (pack: NodePack, field: string) => + (pack as Record)[field], + getSortableFields: () => [{ id: 'name', direction: 'asc' }] + }) +})) + +vi.mock( + '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks', + () => ({ + useInstalledPacks: () => ({ + startFetchInstalled: state.startFetchInstalled, + filterInstalledPack: (packs: NodePack[]) => + packs.filter((p) => state.installedIds.has(p.id ?? '')), + installedPacks: ref(state.installed), + isLoading: ref(state.installedLoading), + isReady: ref(state.installedReady) + }) + }) +) + +vi.mock( + '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks', + () => ({ + useWorkflowPacks: () => ({ + startFetchWorkflowPacks: state.startFetchWorkflowPacks, + filterWorkflowPack: (packs: NodePack[]) => packs, + workflowPacks: ref(state.workflow), + isLoading: ref(state.workflowLoading), + isReady: ref(state.workflowReady) + }) + }) +) + +vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({ + useComfyManagerStore: () => ({ + isPackInstalled: (id: string | undefined) => + state.installedIds.has(id ?? ''), + getInstalledPackVersion: (id: string) => state.installedVersions[id] + }) +})) + +vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({ + useConflictDetectionStore: () => ({ + get conflictedPackages() { + return state.conflicts + } + }) +})) + +function pack(id: string, latestVersion?: string): NodePack { + return { + id, + name: id, + latest_version: latestVersion ? { version: latestVersion } : undefined + } as NodePack +} + +function display( + tab: ManagerTab, + searchResults: NodePack[] = [], + query = '', + sortField = '' +) { + return useManagerDisplayPacks( + ref(tab), + ref(searchResults), + ref(query), + ref(sortField) + ) +} + +beforeEach(() => { + state.installed = [] + state.workflow = [] + state.installedLoading = false + state.workflowLoading = false + state.installedReady = true + state.workflowReady = true + state.startFetchInstalled.mockReset() + state.startFetchWorkflowPacks.mockReset() + state.installedIds = new Set() + state.installedVersions = {} + state.conflicts = [] +}) + +describe('useManagerDisplayPacks', () => { + it('All tab returns the raw search results', () => { + const results = [pack('a'), pack('b')] + expect(display(ManagerTab.All, results).displayPacks.value).toEqual(results) + }) + + it('NotInstalled tab excludes installed packs', () => { + state.installedIds = new Set(['a']) + const { displayPacks } = display(ManagerTab.NotInstalled, [ + pack('a'), + pack('b') + ]) + expect(displayPacks.value.map((p) => p.id)).toEqual(['b']) + }) + + it('AllInstalled tab shows installed packs when not searching', () => { + state.installed = [pack('x'), pack('y')] + const { displayPacks } = display(ManagerTab.AllInstalled) + expect(displayPacks.value.map((p) => p.id)).toEqual(['x', 'y']) + }) + + it('UpdateAvailable tab keeps only installed packs with a newer latest version', () => { + state.installedIds = new Set(['old', 'current', 'nightly']) + state.installedVersions = { + old: '1.0.0', + current: '2.0.0', + nightly: 'not-semver' + } + state.installed = [ + pack('old', '1.2.0'), + pack('current', '2.0.0'), + pack('nightly', '9.9.9'), + pack('uninstalled', '5.0.0') + ] + const { displayPacks } = display(ManagerTab.UpdateAvailable) + expect(displayPacks.value.map((p) => p.id)).toEqual(['old']) + }) + + it('Conflicting tab keeps packs flagged by the conflict store', () => { + state.installed = [pack('a'), pack('b')] + state.conflicts = [{ package_id: 'b' }] + const { displayPacks } = display(ManagerTab.Conflicting) + expect(displayPacks.value.map((p) => p.id)).toEqual(['b']) + }) + + it('Missing tab returns workflow packs that are not installed', () => { + state.workflow = [pack('a'), pack('b')] + state.installedIds = new Set(['a']) + const { displayPacks, missingNodePacks } = display(ManagerTab.Missing) + expect(displayPacks.value.map((p) => p.id)).toEqual(['b']) + expect(missingNodePacks.value.map((p) => p.id)).toEqual(['b']) + }) + + it('Unresolved tab is always empty', () => { + expect( + display(ManagerTab.Unresolved, [pack('a')]).displayPacks.value + ).toEqual([]) + }) + + it('reports loading state scoped to the active tab group', () => { + state.installedLoading = true + state.workflowLoading = false + expect(display(ManagerTab.AllInstalled).isLoading.value).toBe(true) + expect(display(ManagerTab.All).isLoading.value).toBe(false) + + state.installedLoading = false + state.workflowLoading = true + expect(display(ManagerTab.Workflow).isLoading.value).toBe(true) + expect(display(ManagerTab.Missing).isLoading.value).toBe(true) + }) + + it('fetches installed packs when an installed tab is selected and not ready', () => { + state.installedReady = false + display(ManagerTab.AllInstalled) + + expect(state.startFetchInstalled).toHaveBeenCalledTimes(1) + expect(state.startFetchWorkflowPacks).not.toHaveBeenCalled() + }) + + it('fetches workflow and installed packs for missing workflow dependencies', () => { + state.installedReady = false + state.workflowReady = false + display(ManagerTab.Missing) + + expect(state.startFetchInstalled).toHaveBeenCalledTimes(1) + expect(state.startFetchWorkflowPacks).toHaveBeenCalledTimes(1) + }) + + it('filters search results to installed packs on the AllInstalled tab while searching', () => { + state.installedIds = new Set(['a']) + const { displayPacks } = display( + ManagerTab.AllInstalled, + [pack('a'), pack('b')], + 'query' + ) + expect(displayPacks.value.map((p) => p.id)).toEqual(['a']) + }) + + it('filters searched update and conflict tabs before applying tab rules', () => { + state.installedIds = new Set(['old', 'conflict']) + state.installedVersions = { + old: '1.0.0', + conflict: '1.0.0' + } + state.conflicts = [{ package_id: 'conflict' }] + const results = [ + pack('old', '2.0.0'), + pack('current', '1.0.0'), + pack('conflict', '1.0.0') + ] + + expect( + display( + ManagerTab.UpdateAvailable, + results, + 'query' + ).displayPacks.value.map((p) => p.id) + ).toEqual(['old']) + expect( + display(ManagerTab.Conflicting, results, 'query').displayPacks.value.map( + (p) => p.id + ) + ).toEqual(['conflict']) + }) + + it('filters workflow search results on the Workflow tab while searching', () => { + const { displayPacks } = display( + ManagerTab.Workflow, + [pack('a'), pack('b')], + 'query' + ) + expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b']) + }) + + it('filters searched missing workflow packs to not-installed packs', () => { + state.installedIds = new Set(['a']) + const { displayPacks } = display( + ManagerTab.Missing, + [pack('a'), pack('b')], + 'query' + ) + expect(displayPacks.value.map((p) => p.id)).toEqual(['b']) + }) + + it('falls back to search results for unknown tabs', () => { + const results = [pack('a')] + expect( + display('unknown' as ManagerTab, results).displayPacks.value + ).toEqual(results) + }) + + it('sorts installed packs by the configured field', () => { + state.installed = [pack('b'), pack('a'), pack('c')] + const { displayPacks } = display(ManagerTab.AllInstalled, [], '', 'name') + expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b', 'c']) + }) +}) diff --git a/vite.config.mts b/vite.config.mts index 7d1570c2721..0b37e788fe3 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -29,6 +29,54 @@ const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true' const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true' const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false' const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook' +const COVERAGE_CRITICAL = process.env.COVERAGE_CRITICAL === 'true' + +const CRITICAL_COVERAGE_INCLUDE = [ + 'src/base/**/*.{ts,vue}', + 'src/composables/**/*.{ts,vue}', + 'src/core/**/*.{ts,vue}', + 'src/lib/litegraph/src/node/**/*.{ts,vue}', + 'src/lib/litegraph/src/subgraph/**/*.{ts,vue}', + 'src/lib/litegraph/src/utils/**/*.{ts,vue}', + 'src/platform/assets/composables/**/*.{ts,vue}', + 'src/platform/assets/mappings/**/*.{ts,vue}', + 'src/platform/assets/schemas/**/*.{ts,vue}', + 'src/platform/assets/services/**/*.{ts,vue}', + 'src/platform/assets/utils/**/*.{ts,vue}', + 'src/platform/errorCatalog/**/*.{ts,vue}', + 'src/platform/keybindings/**/*.{ts,vue}', + 'src/platform/missingMedia/**/*.{ts,vue}', + 'src/platform/missingModel/**/*.{ts,vue}', + 'src/platform/navigation/**/*.{ts,vue}', + 'src/platform/nodeReplacement/**/*.{ts,vue}', + 'src/platform/remote/**/*.{ts,vue}', + 'src/platform/remoteConfig/**/*.{ts,vue}', + 'src/platform/secrets/**/*.{ts,vue}', + 'src/platform/settings/**/*.{ts,vue}', + 'src/platform/workflow/**/*.{ts,vue}', + 'src/platform/workspace/api/**/*.{ts,vue}', + 'src/platform/workspace/auth/**/*.{ts,vue}', + 'src/platform/workspace/composables/**/*.{ts,vue}', + 'src/platform/workspace/stores/**/*.{ts,vue}', + 'src/platform/workspace/utils/**/*.{ts,vue}', + 'src/schemas/**/*.{ts,vue}', + 'src/scripts/**/*.{ts,vue}', + 'src/services/**/*.{ts,vue}', + 'src/stores/**/*.{ts,vue}', + 'src/utils/**/*.{ts,vue}', + 'src/workbench/extensions/manager/composables/**/*.{ts,vue}', + 'src/workbench/extensions/manager/services/**/*.{ts,vue}', + 'src/workbench/extensions/manager/stores/**/*.{ts,vue}', + 'src/workbench/extensions/manager/utils/**/*.{ts,vue}', + 'src/workbench/utils/**/*.{ts,vue}' +] + +const CRITICAL_COVERAGE_THRESHOLDS = { + statements: 69, + branches: 60, + functions: 67, + lines: 70 +} // Open Graph / Twitter Meta Tags Constants const VITE_OG_URL = 'https://cloud.comfy.org' @@ -668,16 +716,19 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], - include: ['src/**/*.{ts,vue}'], + include: COVERAGE_CRITICAL + ? CRITICAL_COVERAGE_INCLUDE + : ['src/**/*.{ts,vue}'], exclude: [ 'src/**/*.test.ts', 'src/**/*.spec.ts', 'src/**/*.stories.ts', 'src/**/*.d.ts', 'src/locales/**', - 'src/lib/litegraph/**', + ...(COVERAGE_CRITICAL ? [] : ['src/lib/litegraph/**']), 'src/assets/**' - ] + ], + ...(COVERAGE_CRITICAL ? { thresholds: CRITICAL_COVERAGE_THRESHOLDS } : {}) }, exclude: [ '**/node_modules/**',