mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
4 Commits
codex/cove
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8cf13ea3 | ||
|
|
1ec51d9081 | ||
|
|
9b9fe247df | ||
|
|
9a401ccc23 |
7
.github/workflows/ci-tests-unit.yaml
vendored
7
.github/workflows/ci-tests-unit.yaml
vendored
@@ -35,8 +35,8 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run Vitest critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
if: always() && github.event_name == 'push'
|
||||
@@ -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
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,15 +67,7 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
if: always() && !cancelled()
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
@@ -14,44 +14,36 @@ 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',
|
||||
{ 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('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(
|
||||
'preserves share auth attribution before redirecting logged-out users',
|
||||
{ tag: '@critical' },
|
||||
async ({ page }) => {
|
||||
await page.goto(new URL('/?share=abc', APP_URL).toString())
|
||||
test('preserves share auth attribution before redirecting logged-out users', 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',
|
||||
{ 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()
|
||||
}
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,38 +97,34 @@ test.describe(
|
||||
'Execute to selected output nodes',
|
||||
{ tag: ['@smoke', '@workflow'] },
|
||||
() => {
|
||||
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('')
|
||||
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 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('')
|
||||
}
|
||||
)
|
||||
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('')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -13,37 +13,33 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
type TestSettingId = keyof Settings
|
||||
|
||||
test.describe('Topbar commands', () => {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
],
|
||||
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
|
||||
|
||||
@@ -22,15 +22,11 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||
// conversion) caused the wrong link to survive during deduplication.
|
||||
|
||||
@@ -8,24 +8,20 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
})
|
||||
|
||||
test(
|
||||
'Can open search and add node',
|
||||
{ tag: '@critical' },
|
||||
async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
test('Can open search and add node', 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
|
||||
|
||||
@@ -33,21 +33,19 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Should show missing models group in errors tab',
|
||||
{ tag: '@critical' },
|
||||
async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
test('Should show missing models group in errors tab', 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')
|
||||
|
||||
@@ -12,25 +12,23 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test(
|
||||
'Should show missing node pack card with guidance',
|
||||
{ tag: '@critical' },
|
||||
async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
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
|
||||
)
|
||||
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/)
|
||||
}
|
||||
)
|
||||
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
|
||||
|
||||
@@ -54,19 +54,13 @@ test.describe('Queue overlay', () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test(
|
||||
'Toggle button opens expanded queue overlay',
|
||||
{ tag: '@critical' },
|
||||
async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
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()
|
||||
}
|
||||
)
|
||||
// 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)
|
||||
|
||||
@@ -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', '@critical'] },
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
|
||||
@@ -51,8 +51,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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(() => 7)
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
clampUsd,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
@@ -44,21 +43,4 @@ describe('comfyCredits helpers', () => {
|
||||
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
|
||||
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
|
||||
})
|
||||
|
||||
test('formats with compatible fraction digit bounds', () => {
|
||||
expect(
|
||||
formatCredits({
|
||||
value: 12.345,
|
||||
locale: 'en-US',
|
||||
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
|
||||
})
|
||||
).toBe('12.35')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,22 +34,17 @@ describe('useSelectionToolboxPosition', () => {
|
||||
canvasStore = useCanvasStore()
|
||||
})
|
||||
|
||||
function renderToolboxForSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {},
|
||||
ds: Partial<LGraphCanvas['ds']> = {}
|
||||
) {
|
||||
function renderToolboxForSelection(item: Positionable) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: ds.offset ?? [0, 0],
|
||||
scale: ds.scale ?? 1
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set(items),
|
||||
selectedItems: new Set([item]),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true,
|
||||
...state
|
||||
selectionChanged: true
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
|
||||
@@ -74,7 +69,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()
|
||||
@@ -86,64 +81,11 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
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<typeof VueI18n>()),
|
||||
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<string, unknown> = {}): 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)
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, 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'
|
||||
@@ -20,11 +19,6 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -33,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
function stubClipboardItem() {
|
||||
vi.stubGlobal(
|
||||
'ClipboardItem',
|
||||
class ClipboardItemStub {
|
||||
constructor(public readonly items: Record<string, Blob>) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createImageNode(
|
||||
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
@@ -60,13 +45,8 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
@@ -202,141 +182,4 @@ 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)
|
||||
options.find((o) => o.label === 'Open Image')?.action?.()
|
||||
options.find((o) => o.label === 'Save Image')?.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<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
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<Clipboard>({ 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<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
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<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
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)
|
||||
)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
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<typeof VueI18n>()),
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
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 }
|
||||
}
|
||||
}))
|
||||
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: null } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: customization.shapeOptions,
|
||||
applyShape: customization.applyShape,
|
||||
applyColor: customization.applyColor,
|
||||
colorOptions: customization.colorOptions,
|
||||
isLightTheme: customization.isLightTheme
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => actions
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
|
||||
return label
|
||||
}
|
||||
|
||||
function readNodeMenuOptions<T>(
|
||||
read: (options: ReturnType<typeof useNodeMenuOptions>) => 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', () => {
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
customization.shapeOptions = []
|
||||
customization.colorOptions = []
|
||||
customization.isLightTheme.value = false
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
|
||||
])
|
||||
).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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
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<unknown>(),
|
||||
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(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('copies a non-empty selection and reports success', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().copySelection()
|
||||
expect(canvas.copyToClipboard).toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
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).toHaveBeenCalled()
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when duplicating nothing', () => {
|
||||
useSelectionOperations().duplicateSelection()
|
||||
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
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(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
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(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,7 @@ 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,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
createMockLGraphNode,
|
||||
@@ -22,9 +17,7 @@ import {
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isLGraphGroup: vi.fn(),
|
||||
isLoad3dNode: vi.fn()
|
||||
isImageNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
@@ -103,14 +96,6 @@ 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'
|
||||
})
|
||||
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
|
||||
nodes.filter((n) => n.type === 'OutputNode')
|
||||
)
|
||||
@@ -150,21 +135,6 @@ 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', () => {
|
||||
@@ -245,13 +215,6 @@ 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', () => {
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
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<string, unknown>,
|
||||
appState: {
|
||||
graph: {
|
||||
nodes: [] as unknown[]
|
||||
}
|
||||
},
|
||||
extensionState: {
|
||||
installed: false,
|
||||
registered: undefined as ComfyExtension | undefined
|
||||
},
|
||||
nodeDefState: {
|
||||
value: null as Record<string, unknown> | 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<NonNullable<ComfyExtension['init']>>[0] {
|
||||
return {} as Parameters<NonNullable<ComfyExtension['init']>>[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])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
@@ -14,7 +12,6 @@ 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'
|
||||
|
||||
@@ -126,35 +123,6 @@ function createMockNode(
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveDisplayPrice(
|
||||
node: LGraphNode,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): Promise<string> {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
getNodeDisplayPrice(node, widgetOverrides)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
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
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -221,32 +189,6 @@ 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(
|
||||
@@ -280,19 +222,6 @@ 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(
|
||||
@@ -309,51 +238,6 @@ 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(
|
||||
@@ -584,42 +468,6 @@ 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()
|
||||
@@ -747,86 +595,6 @@ 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()
|
||||
@@ -975,16 +743,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe('')
|
||||
})
|
||||
|
||||
it('should reuse the cached empty label after runtime failures', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestCachedRuntimeErrorNode',
|
||||
priceBadge('$lookup(undefined, "key")')
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for invalid PricingResult type', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -1210,21 +968,8 @@ 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 })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty for blank string usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: ' ' })
|
||||
const result = formatPricingResult({ type: 'usd', usd: null as never })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1254,14 +999,6 @@ 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', () => {
|
||||
@@ -1280,22 +1017,6 @@ 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', () => {
|
||||
@@ -1303,11 +1024,6 @@ 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', () => {
|
||||
@@ -1474,29 +1190,6 @@ 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',
|
||||
@@ -1572,30 +1265,6 @@ 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',
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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>): 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<Record<string, boolean>>({})
|
||||
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<Record<string, boolean>>({})
|
||||
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<Record<string, boolean>>({})
|
||||
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<Record<string, boolean>>({})
|
||||
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<Record<string, boolean>>({
|
||||
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<Record<string, boolean>>({})
|
||||
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<Record<string, boolean>>({})
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -1,46 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { 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 type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import type { GroupNodeWorkflowData } from './groupNode'
|
||||
|
||||
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
|
||||
app: {
|
||||
registerExtension: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
|
||||
@@ -58,42 +26,6 @@ function makeNode(type: string): ComfyNode {
|
||||
}
|
||||
}
|
||||
|
||||
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): 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<string, ComfyNodeDef>) {
|
||||
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')]
|
||||
@@ -172,389 +104,4 @@ 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<number, number> = {}
|
||||
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<string, GroupNodeWorkflowData> = {
|
||||
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') {
|
||||
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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
getSettingInfo,
|
||||
@@ -10,47 +11,31 @@ 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: auth.isLoggedIn })
|
||||
useCurrentUser: () => ({ isLoggedIn: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billing.isActiveSubscription
|
||||
})
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: featureFlags
|
||||
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
|
||||
})
|
||||
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return dist.isDesktop
|
||||
}
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -64,7 +49,6 @@ interface MockSettingParams {
|
||||
type: string
|
||||
defaultValue: unknown
|
||||
category?: string[]
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
describe('useSettingUI', () => {
|
||||
@@ -88,23 +72,13 @@ describe('useSettingUI', () => {
|
||||
defaultValue: 'dark'
|
||||
}
|
||||
}
|
||||
let settingsById: Record<string, MockSettingParams>
|
||||
|
||||
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
|
||||
settingsById: mockSettings
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
vi.mocked(getSettingInfo).mockImplementation((setting) => {
|
||||
@@ -133,9 +107,9 @@ describe('useSettingUI', () => {
|
||||
undefined,
|
||||
'Comfy.Locale'
|
||||
)
|
||||
expect(defaultCategory.value).toBe(
|
||||
findCategory(settingCategories.value, 'Comfy')
|
||||
)
|
||||
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
|
||||
expect(comfyCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(comfyCategory)
|
||||
})
|
||||
|
||||
it('resolves different category from scrollToSettingId', () => {
|
||||
@@ -147,6 +121,7 @@ describe('useSettingUI', () => {
|
||||
settingCategories.value,
|
||||
'Appearance'
|
||||
)
|
||||
expect(appearanceCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(appearanceCategory)
|
||||
})
|
||||
|
||||
@@ -162,82 +137,4 @@ 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<typeof useSettingStore>)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
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('openedWorkflowIndexShift returns null when no workflow is active', () => {
|
||||
const store = useWorkflowStore()
|
||||
store.attachWorkflow(wf({ path: 'a.json' }), 0)
|
||||
|
||||
expect(store.openedWorkflowIndexShift(1)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
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])
|
||||
})
|
||||
|
||||
it('reports no active workflow before one is opened', () => {
|
||||
const store = useWorkflowStore()
|
||||
expect(store.isActive(wf('a.json'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,240 +0,0 @@
|
||||
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<string, unknown[]> },
|
||||
coreResult: { value: [] as unknown[] },
|
||||
customResult: { value: {} as Record<string, string[]> },
|
||||
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> = {}
|
||||
): 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 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()
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
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<string, unknown>,
|
||||
nodeDefs: {} as Record<string, unknown>,
|
||||
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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
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('routes isobmff by mime type and by file extension', async () => {
|
||||
await getWorkflowDataFromFile(file('video/mp4'))
|
||||
await getWorkflowDataFromFile(file('', 'clip.mov'))
|
||||
await getWorkflowDataFromFile(file('', 'clip.m4v'))
|
||||
expect(getFromIsobmffFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
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 }])
|
||||
})
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
|
||||
const authStoreMock = vi.hoisted(() => ({
|
||||
createCustomer: vi.fn()
|
||||
}))
|
||||
|
||||
const toastStoreMock = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
|
||||
const errorHandlingMock = vi.hoisted(() => ({
|
||||
toastErrorHandler: vi.fn(),
|
||||
forceGenericFailure: false,
|
||||
forceStorageFailure: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => authStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => toastStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: errorHandlingMock.toastErrorHandler,
|
||||
wrapWithErrorHandlingAsync:
|
||||
(
|
||||
fn: (value?: string) => Promise<boolean>,
|
||||
onError: (e: unknown) => void
|
||||
) =>
|
||||
async (value?: string) => {
|
||||
try {
|
||||
if (errorHandlingMock.forceStorageFailure) {
|
||||
throw new Error('STORAGE_FAILED')
|
||||
}
|
||||
if (errorHandlingMock.forceGenericFailure) {
|
||||
throw new Error('OTHER_FAILED')
|
||||
}
|
||||
return await fn(value)
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
describe('apiKeyAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
authStoreMock.createCustomer.mockReset()
|
||||
toastStoreMock.add.mockClear()
|
||||
errorHandlingMock.toastErrorHandler.mockClear()
|
||||
errorHandlingMock.forceGenericFailure = false
|
||||
errorHandlingMock.forceStorageFailure = false
|
||||
})
|
||||
|
||||
it('stores an API key, initializes the user, and returns an auth header', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(true)
|
||||
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.getApiKey()).toBe('secret')
|
||||
expect(store.getAuthHeader()).toEqual({ 'X-API-KEY': 'secret' })
|
||||
expect(toastStoreMock.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the user when the API key is cleared', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await store.storeApiKey('secret')
|
||||
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
|
||||
await expect(store.clearStoredApiKey()).resolves.toBe(true)
|
||||
|
||||
expect(store.currentUser).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.getAuthHeader()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears an API key when no associated user is found', async () => {
|
||||
authStoreMock.createCustomer.mockResolvedValue(undefined)
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('orphaned-secret')).resolves.toBe(true)
|
||||
await vi.waitFor(() => expect(store.getApiKey()).toBeNull())
|
||||
|
||||
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'auth.login.noAssociatedUser' })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports storage failures through the API-key toast copy', async () => {
|
||||
errorHandlingMock.forceStorageFailure = true
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(false)
|
||||
|
||||
expect(toastStoreMock.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'auth.apiKey.storageFailed',
|
||||
detail: 'auth.apiKey.storageFailedDetail'
|
||||
})
|
||||
expect(errorHandlingMock.toastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports non-storage failures through the generic toast handler', async () => {
|
||||
errorHandlingMock.forceGenericFailure = true
|
||||
const store = useApiKeyAuthStore()
|
||||
|
||||
await expect(store.storeApiKey('secret')).resolves.toBe(false)
|
||||
|
||||
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,20 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
|
||||
currentUser.value = createCustomerResponse
|
||||
}
|
||||
|
||||
watch(
|
||||
apiKey,
|
||||
() => {
|
||||
if (apiKey.value) {
|
||||
// IF API key is set, initialize user
|
||||
void initializeUserFromApiKey()
|
||||
} else {
|
||||
// IF API key is cleared, clear user
|
||||
currentUser.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const reportError = (error: unknown) => {
|
||||
if (error instanceof Error && error.message === 'STORAGE_FAILED') {
|
||||
toastStore.add({
|
||||
@@ -49,20 +63,6 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
apiKey,
|
||||
() => {
|
||||
if (apiKey.value) {
|
||||
// IF API key is set, initialize user
|
||||
void initializeUserFromApiKey().catch(reportError)
|
||||
} else {
|
||||
// IF API key is cleared, clear user
|
||||
currentUser.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const storeApiKey = wrapWithErrorHandlingAsync(async (newApiKey: string) => {
|
||||
apiKey.value = newApiKey
|
||||
toastStore.add({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -56,13 +56,9 @@ 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: () => ({ state: mockCanvas.state })
|
||||
getCanvas: () => ({ read_only: false })
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -166,7 +162,6 @@ 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()
|
||||
@@ -370,83 +365,6 @@ 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<LGraphNode, unknown>({
|
||||
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<SerializedNodeId, unknown>(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]
|
||||
@@ -481,32 +399,6 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedOutputs).toEqual([toNodeId(1)])
|
||||
})
|
||||
|
||||
it('drops malformed output ids on load', () => {
|
||||
store.loadSelections({
|
||||
outputs: [fromAny<SerializedNodeId, unknown>('')]
|
||||
})
|
||||
|
||||
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'])
|
||||
|
||||
@@ -589,7 +481,7 @@ describe('appModeStore', () => {
|
||||
expect(
|
||||
store.pruneLinearData({
|
||||
inputs: [[1, 'seed']],
|
||||
outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')]
|
||||
outputs: [toNodeId(1)]
|
||||
})
|
||||
).toEqual({
|
||||
inputs: [[1, 'seed']],
|
||||
@@ -749,17 +641,6 @@ 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
|
||||
@@ -874,24 +755,6 @@ 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<IBaseWidget, unknown>({}))
|
||||
|
||||
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<IBaseWidget, unknown>({ widgetId: 'g:2:prompt' })
|
||||
)
|
||||
|
||||
expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
@@ -956,47 +819,6 @@ 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 = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvas.state.readOnly).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy selectedInput tuple migration', () => {
|
||||
@@ -1085,121 +907,6 @@ 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<LGraphNode, unknown>({
|
||||
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
|
||||
|
||||
@@ -126,19 +126,6 @@ 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()
|
||||
|
||||
@@ -192,19 +179,6 @@ 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()
|
||||
|
||||
@@ -337,22 +311,5 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
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<typeof VueUse>()),
|
||||
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> = {}
|
||||
): 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')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -12,7 +11,6 @@ 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', () => ({
|
||||
@@ -98,10 +96,6 @@ const mockOutputOverrides = vi.hoisted(() => ({
|
||||
value: null as MockOutput[] | null
|
||||
}))
|
||||
|
||||
const mockAssetMapperOptions = vi.hoisted(() => ({
|
||||
omitCreatedAtForIds: new Set<string>()
|
||||
}))
|
||||
|
||||
// Mock TaskItemImpl
|
||||
const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio'])
|
||||
|
||||
@@ -175,14 +169,11 @@ 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,
|
||||
...(!mockAssetMapperOptions.omitCreatedAtForIds.has(task.jobId) && {
|
||||
created_at: createdAt
|
||||
}),
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {}
|
||||
@@ -214,7 +205,6 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useAssetsStore()
|
||||
vi.clearAllMocks()
|
||||
mockAssetMapperOptions.omitCreatedAtForIds.clear()
|
||||
})
|
||||
|
||||
describe('Initial Load', () => {
|
||||
@@ -282,17 +272,6 @@ 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', () => {
|
||||
@@ -349,46 +328,6 @@ 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) =>
|
||||
@@ -555,29 +494,6 @@ 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', () => {
|
||||
@@ -1008,43 +924,6 @@ 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', () => {
|
||||
@@ -1087,10 +966,6 @@ 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 () => {
|
||||
@@ -1100,63 +975,6 @@ 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', () => {
|
||||
@@ -1311,140 +1129,7 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
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<string, unknown>
|
||||
}
|
||||
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<string, unknown>
|
||||
}
|
||||
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<string, unknown>
|
||||
}
|
||||
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<string, unknown>
|
||||
}
|
||||
const missing = {
|
||||
...createMockAsset('opt-missing-from-cache'),
|
||||
user_metadata: { note: 'before' } as Record<string, unknown>
|
||||
}
|
||||
|
||||
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 = {
|
||||
@@ -1552,31 +1237,6 @@ 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', () => {
|
||||
@@ -1691,36 +1351,6 @@ 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'])
|
||||
@@ -1853,78 +1483,9 @@ 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<Response, unknown>({
|
||||
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<Response, unknown>({
|
||||
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 {
|
||||
@@ -2025,18 +1586,6 @@ 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(
|
||||
@@ -2251,26 +1800,4 @@ 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<AssetResponse>((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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -849,15 +849,18 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
updateModelsForTag('models')
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
[...nodeTypeUpdates, ...tagUpdates].map((update) =>
|
||||
update.catch((reason) => {
|
||||
console.error(
|
||||
`Failed to refresh model cache for provider: ${reason}`
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ 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()
|
||||
@@ -100,8 +99,7 @@ vi.mock('firebase/auth', async (importOriginal) => {
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
getAdditionalUserInfo: vi.fn(),
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined),
|
||||
updatePassword: vi.fn()
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,18 +127,6 @@ 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', () => ({
|
||||
@@ -177,9 +163,6 @@ 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({
|
||||
@@ -292,11 +275,6 @@ 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', () => {
|
||||
@@ -314,24 +292,6 @@ 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')
|
||||
@@ -389,30 +349,6 @@ 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<UserCredential> 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<UserCredential> 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 }
|
||||
@@ -550,19 +486,6 @@ 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<UserCredential> as UserCredential)
|
||||
|
||||
await store.register('new@example.com', 'password')
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
@@ -696,54 +619,6 @@ 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', () => {
|
||||
@@ -929,22 +804,6 @@ 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<UserCredential> as UserCredential)
|
||||
|
||||
await store[method]()
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1116,61 +975,6 @@ 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', () => {
|
||||
@@ -1258,117 +1062,5 @@ 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,17 +93,6 @@ 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -178,10 +177,9 @@ 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(
|
||||
fromAny<Parameters<typeof store.getPackById.call>[0], unknown>(null)
|
||||
)
|
||||
const result = await store.getPackById.call(null!)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()
|
||||
@@ -208,56 +206,6 @@ 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<components['schemas']['Node'], unknown>({ 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<components['schemas']['Node']['id'][], unknown>([
|
||||
'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()
|
||||
|
||||
@@ -4,10 +4,6 @@ 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:
|
||||
@@ -25,13 +21,12 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => keybindingMock.value
|
||||
getKeybindingByCommandId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
describe('commandStore', () => {
|
||||
beforeEach(() => {
|
||||
keybindingMock.value = null
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
@@ -169,16 +164,6 @@ 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({
|
||||
@@ -199,16 +184,6 @@ 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', () => {
|
||||
@@ -218,17 +193,5 @@ 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -141,110 +141,6 @@ 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()
|
||||
|
||||
@@ -312,86 +208,6 @@ 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', () => {
|
||||
|
||||
@@ -112,36 +112,6 @@ 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', () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
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
|
||||
})
|
||||
}))
|
||||
|
||||
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<typeof downloadManagerMock.onDownloadProgress>[0]
|
||||
| undefined
|
||||
downloadManagerMock.onDownloadProgress.mockImplementation((callback) => {
|
||||
progressCallback = callback
|
||||
})
|
||||
const store = useElectronDownloadStore()
|
||||
|
||||
await store.initialize()
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -33,15 +33,18 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
|
||||
}
|
||||
|
||||
DownloadManager.onDownloadProgress((data) => {
|
||||
const download = findByUrl(data.url)
|
||||
if (!download) {
|
||||
if (!findByUrl(data.url)) {
|
||||
downloads.value.push(data)
|
||||
return
|
||||
}
|
||||
download.progress = data.progress
|
||||
download.status = data.status
|
||||
download.filename = data.filename
|
||||
download.savePath = data.savePath
|
||||
|
||||
const download = findByUrl(data.url)
|
||||
|
||||
if (download) {
|
||||
download.progress = data.progress
|
||||
download.status = data.status
|
||||
download.filename = data.filename
|
||||
download.savePath = data.savePath
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>(),
|
||||
errorStore: {
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
} as Record<string, unknown>,
|
||||
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<string, unknown>) {
|
||||
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', exception_type: 'AccountError' })
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
expect(errorStore.lastExecutionError).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: [] } })
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,8 @@ 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', () => ({
|
||||
@@ -17,53 +15,6 @@ 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<typeof mockExecutionIdToNodeLocatorId>
|
||||
) => mockExecutionIdToNodeLocatorId(...args),
|
||||
forEachNode: vi.fn(),
|
||||
getExecutionIdByNode: (
|
||||
...args: Parameters<typeof mockGetExecutionIdByNode>
|
||||
) => mockGetExecutionIdByNode(...args),
|
||||
getNodeByExecutionId: (
|
||||
...args: Parameters<typeof mockGetNodeByExecutionId>
|
||||
) => mockGetNodeByExecutionId(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
@@ -88,21 +39,6 @@ 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.mockImplementation(
|
||||
(locator: NodeLocatorId) =>
|
||||
toNodeId(String(locator).split(':').at(-1) ?? locator)
|
||||
)
|
||||
})
|
||||
|
||||
describe('executionErrorStore — node error operations', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -208,31 +144,6 @@ 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 = {
|
||||
@@ -477,358 +388,6 @@ 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<NodeLocatorId, string>('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<NodeLocatorId, undefined>(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<MissingNodeType[], unknown>([{ 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<NodeLocatorId, undefined>(undefined)
|
||||
: fromAny<NodeLocatorId, string>(`locator:${id}`)
|
||||
)
|
||||
store.lastNodeErrors = {
|
||||
'1': nodeError,
|
||||
missing: nodeError
|
||||
}
|
||||
|
||||
const locator = fromAny<NodeLocatorId, string>('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<NodeLocatorId, string>('locator:missing'))
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns no slot error when there are no node errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
expect(
|
||||
store.slotHasError(fromAny<NodeLocatorId, string>('locator:1'), 'x')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('detects container nodes with internal errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const node = fromAny<LGraphNode, unknown>({})
|
||||
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<LGraphNode, unknown>({}))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -898,23 +457,6 @@ 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', () => {
|
||||
@@ -983,23 +525,6 @@ 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', () => {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>()
|
||||
}))
|
||||
|
||||
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<typeof useExecutionStore>,
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
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<string, (e: { detail: unknown }) => 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<typeof useExecutionStore>,
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => 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
|
||||
}))
|
||||
|
||||
interface NodeState {
|
||||
state: string
|
||||
value?: number
|
||||
max?: number
|
||||
node_id?: string
|
||||
}
|
||||
|
||||
function progressState(jobId: string, nodes: Record<string, NodeState>) {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
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<typeof classifyCloudValidationError>
|
||||
|
||||
const { handlers, errorStore, activeWorkflow, dist, classifyCloud } =
|
||||
vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
errorStore: {
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
} as Record<string, unknown>,
|
||||
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: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -423,124 +423,6 @@ 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', () => {
|
||||
@@ -669,31 +551,6 @@ 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', () => {
|
||||
@@ -818,16 +675,6 @@ 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')
|
||||
@@ -844,14 +691,6 @@ 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.
|
||||
@@ -1061,35 +900,6 @@ 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<typeof toNodeId>,
|
||||
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')
|
||||
@@ -1105,19 +915,6 @@ 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', () => {
|
||||
@@ -1578,21 +1375,6 @@ 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', () => {
|
||||
@@ -1780,35 +1562,9 @@ 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 = {
|
||||
@@ -1834,31 +1590,7 @@ 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('')
|
||||
})
|
||||
})
|
||||
|
||||
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' }
|
||||
|
||||
@@ -1878,18 +1610,6 @@ 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'
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution_error', () => {
|
||||
@@ -1911,39 +1631,6 @@ 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()
|
||||
|
||||
@@ -2057,12 +1744,6 @@ 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', () => {
|
||||
@@ -2132,45 +1813,6 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('storeJob works without workflow metadata', () => {
|
||||
const workflow = {} as Parameters<typeof store.storeJob>[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')
|
||||
@@ -2187,58 +1829,4 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,9 +153,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
pendingWorkflowStatusByJobId.delete(jobId)
|
||||
pendingWorkflowStatusByJobId.set(jobId, status)
|
||||
while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) {
|
||||
pendingWorkflowStatusByJobId.delete(
|
||||
pendingWorkflowStatusByJobId.keys().next().value as string
|
||||
)
|
||||
const oldest = pendingWorkflowStatusByJobId.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
pendingWorkflowStatusByJobId.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,8 +314,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
: null
|
||||
)
|
||||
|
||||
const activeJob = computed<QueuedJob | undefined>(() =>
|
||||
activeJobId.value ? queuedJobs.value[activeJobId.value] : undefined
|
||||
const activeJob = computed<QueuedJob | undefined>(
|
||||
() => queuedJobs.value[activeJobId.value ?? '']
|
||||
)
|
||||
|
||||
const totalNodesToExecute = computed<number>(() => {
|
||||
@@ -440,7 +440,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
// Update the executing nodes list
|
||||
if (e.detail == null) {
|
||||
delete queuedJobs.value[activeJobId.value as JobId]
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
}
|
||||
activeJobId.value = null
|
||||
}
|
||||
}
|
||||
@@ -591,7 +593,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)
|
||||
@@ -667,14 +669,17 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
/**
|
||||
* Reset execution-related state after a run completes or is stopped.
|
||||
*/
|
||||
function resetExecutionState(jobId: JobId) {
|
||||
function resetExecutionState(jobIdParam?: JobId | null) {
|
||||
executionIdToLocatorCache.clear()
|
||||
nodeProgressStates.value = {}
|
||||
const map = { ...nodeProgressStatesByJob.value }
|
||||
delete map[jobId]
|
||||
nodeProgressStatesByJob.value = map
|
||||
useJobPreviewStore().clearPreview(jobId)
|
||||
jobIdToWorkflow.delete(jobId)
|
||||
const jobId = jobIdParam ?? activeJobId.value ?? null
|
||||
if (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]
|
||||
}
|
||||
@@ -766,7 +771,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const next = new Map(jobIdToSessionWorkflowPath.value)
|
||||
next.set(jobId, path)
|
||||
while (next.size > MAX_SESSION_PATH_ENTRIES) {
|
||||
next.delete(next.keys().next().value as JobId)
|
||||
const oldest = next.keys().next().value
|
||||
if (oldest !== undefined) next.delete(oldest)
|
||||
else break
|
||||
}
|
||||
jobIdToSessionWorkflowPath.value = next
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
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<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>()
|
||||
}))
|
||||
|
||||
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<typeof useExecutionStore>,
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
|
||||
@@ -71,14 +71,6 @@ 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')
|
||||
@@ -99,24 +91,6 @@ 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()
|
||||
@@ -125,15 +99,4 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
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<void>, 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')
|
||||
})
|
||||
})
|
||||
@@ -137,88 +137,6 @@ 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()
|
||||
@@ -291,23 +209,6 @@ 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', () => {
|
||||
|
||||
@@ -69,9 +69,7 @@ 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.slice(
|
||||
this.normalized_file_name.lastIndexOf('/') + 1
|
||||
)
|
||||
this.simplified_file_name = this.normalized_file_name.split('/').pop() ?? ''
|
||||
if (this.simplified_file_name.endsWith('.safetensors')) {
|
||||
this.simplified_file_name = this.simplified_file_name.slice(
|
||||
0,
|
||||
|
||||
@@ -138,22 +138,6 @@ 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()
|
||||
@@ -593,22 +577,6 @@ 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()
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
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<string, unknown>,
|
||||
setSpy: vi.fn(),
|
||||
nodeDefs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
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('builds the bookmark tree, dropping unknown node defs', () => {
|
||||
nodeDefs['KSampler'] = leafNode('KSampler')
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'sampling/Unknown', 'Folder/']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const children = (store.bookmarkedRoot as { children: unknown[] }).children
|
||||
expect(children).toHaveLength(2)
|
||||
})
|
||||
|
||||
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']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
|
||||
'New/',
|
||||
'New/KSampler',
|
||||
'Other/Node'
|
||||
])
|
||||
})
|
||||
|
||||
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']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.deleteBookmarkFolder(folderNode('Old/'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node'])
|
||||
})
|
||||
|
||||
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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,9 +50,9 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
|
||||
.map((bookmark: string) => {
|
||||
if (bookmark.endsWith('/')) return createDummyFolderNodeDef(bookmark)
|
||||
|
||||
const slashIndex = bookmark.lastIndexOf('/')
|
||||
const name = bookmark.slice(slashIndex + 1)
|
||||
const category = bookmark.slice(0, Math.max(0, slashIndex))
|
||||
const parts = bookmark.split('/')
|
||||
const name = parts.pop() ?? ''
|
||||
const category = parts.join('/')
|
||||
const srcNodeDef = nodeDefStore.allNodeDefsByName[name]
|
||||
if (!srcNodeDef) {
|
||||
return null
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import axios from 'axios'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } 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 {
|
||||
ComfyNodeDefImpl,
|
||||
buildNodeDefTree,
|
||||
createDummyFolderNodeDef,
|
||||
useNodeDefStore,
|
||||
useNodeFrequencyStore
|
||||
} from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { NodeDefFilter } from '@/stores/nodeDefStore'
|
||||
|
||||
describe('useNodeDefStore', () => {
|
||||
@@ -29,10 +21,6 @@ describe('useNodeDefStore', () => {
|
||||
store = useNodeDefStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createMockNodeDef = (
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef => ({
|
||||
@@ -51,112 +39,7 @@ 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<ComfyNodeDef, unknown>({
|
||||
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',
|
||||
@@ -404,26 +287,6 @@ describe('useNodeDefStore', () => {
|
||||
})
|
||||
|
||||
describe('allNodeDefsByName', () => {
|
||||
it('keeps existing ComfyNodeDefImpl instances during updates', () => {
|
||||
const nodeDef = new ComfyNodeDefImpl(
|
||||
createMockNodeDef({ name: 'ExistingImpl' })
|
||||
)
|
||||
|
||||
store.updateNodeDefs([nodeDef])
|
||||
|
||||
expect(store.nodeDefsByName.ExistingImpl.name).toBe('ExistingImpl')
|
||||
expect(store.nodeDefsByDisplayName['Test Node'].name).toBe('ExistingImpl')
|
||||
})
|
||||
|
||||
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' })
|
||||
@@ -473,39 +336,6 @@ 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', () => {
|
||||
@@ -559,94 +389,6 @@ 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', () => {
|
||||
|
||||
@@ -105,12 +105,8 @@ export class ComfyNodeDefImpl
|
||||
* @internal
|
||||
* Migrate default input options to forceInput.
|
||||
*/
|
||||
private static _migrateDefaultInput(
|
||||
nodeDef: ComfyNodeDefV1
|
||||
): ComfyNodeDefV1 & { input: ComfyInputSpecV1 } {
|
||||
const def = _.cloneDeep(nodeDef) as ComfyNodeDefV1 & {
|
||||
input: ComfyInputSpecV1
|
||||
}
|
||||
private static _migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 {
|
||||
const def = _.cloneDeep(nodeDef)
|
||||
def.input ??= {}
|
||||
// For required inputs, now we have the input socket always present. Specifying
|
||||
// it now has no effect.
|
||||
@@ -160,7 +156,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
|
||||
|
||||
@@ -3,41 +3,15 @@ import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } 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,
|
||||
createNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { createNodeExecutionId } 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', () => ({
|
||||
@@ -46,25 +20,11 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNode: (...args: unknown[]) => mockResolveNode(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (...args: Parameters<typeof mockApiURL>) => 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)
|
||||
},
|
||||
@@ -89,31 +49,13 @@ const createMockOutputs = (
|
||||
): ExecutedWsMessage['output'] => ({ images })
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: (
|
||||
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
|
||||
) => mockExecutionIdToNodeLocatorId(...args)
|
||||
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
|
||||
}))
|
||||
|
||||
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: (
|
||||
...args: Parameters<typeof mockNodeIdToNodeLocatorId>
|
||||
) => mockNodeIdToNodeLocatorId(...args),
|
||||
nodeToNodeLocatorId: (
|
||||
...args: Parameters<typeof mockNodeToNodeLocatorId>
|
||||
) => mockNodeToNodeLocatorId(...args)
|
||||
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
|
||||
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -838,19 +780,6 @@ 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<NodeLocatorId, undefined>(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 })
|
||||
@@ -860,470 +789,6 @@ 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<NodeLocatorId, undefined>(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<ExecutedWsMessage['output'], unknown>(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<SubgraphNode, unknown>({
|
||||
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<SubgraphNode, unknown>({
|
||||
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<SubgraphNode, unknown>({
|
||||
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<NodeLocatorId, undefined>(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<NodeLocatorId, undefined>(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<NodeLocatorId, undefined>(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<NodeLocatorId, undefined>(undefined)
|
||||
)
|
||||
|
||||
store.restoreOutputs({ missing: output })
|
||||
|
||||
expect(store.nodeOutputs.missing).toEqual(output)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeOutputStore syncLegacyNodeImgs', () => {
|
||||
@@ -1429,20 +894,4 @@ 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' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,22 +95,6 @@ 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', () => {
|
||||
@@ -138,12 +122,6 @@ 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', () => {
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
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', () => {
|
||||
expect(item({ mediaType: 'text' }).isText).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 bin = item({ filename: 'a.bin', mediaType: 'binary' })
|
||||
expect(ResultItemImpl.filterPreviewable([png, bin])).toEqual([png])
|
||||
|
||||
expect(ResultItemImpl.findByUrl([png, bin], png.url)).toBe(0)
|
||||
expect(ResultItemImpl.findByUrl([png, bin], 'no-match')).toBe(0)
|
||||
expect(ResultItemImpl.findByUrl([png, bin])).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -10,8 +10,6 @@ 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'
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
@@ -46,9 +44,7 @@ const mockJobDetail = {
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'1': {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }]
|
||||
}
|
||||
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,98 +137,4 @@ 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<JobDetail>({ 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<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
expect(setOutputsSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should skip workflow output loading when no outputs exist', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const task = new TaskItemImpl(job, fromAny<TaskOutput, unknown>(null))
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const setOutputsSpy = vi.spyOn(
|
||||
nodeOutputStore,
|
||||
'setNodeOutputsByExecutionId'
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
expect(setOutputsSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip invalid node execution ids while loading outputs', async () => {
|
||||
const job = createHistoryJob('test-job-id')
|
||||
const outputs = fromAny<TaskOutput, unknown>({
|
||||
'': { 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<JobDetail>({ ...mockJobDetail, outputs: undefined })
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(setOutputsSpy).toHaveBeenCalledOnce()
|
||||
expect(setOutputsSpy).toHaveBeenCalledWith('1', outputs['1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -7,14 +6,7 @@ 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 {
|
||||
isInstantMode,
|
||||
isInstantRunningMode,
|
||||
ResultItemImpl,
|
||||
TaskItemImpl,
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
// Fixture factory for JobListItem
|
||||
function createJob(
|
||||
@@ -75,86 +67,6 @@ vi.mock('@/scripts/api', () => ({
|
||||
}))
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
it('should default missing result URL fields', () => {
|
||||
const output = new ResultItemImpl(
|
||||
fromAny<ConstructorParameters<typeof ResultItemImpl>[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, {
|
||||
@@ -347,41 +259,6 @@ 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<TaskOutput, unknown>(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', () => {
|
||||
@@ -437,23 +314,6 @@ describe('useQueueStore', () => {
|
||||
expect(store.pendingTasks[1].jobId).toBe('pend-1')
|
||||
})
|
||||
|
||||
it('should register workflow ids for active jobs', async () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const registerSpy = vi.spyOn(
|
||||
executionStore,
|
||||
'registerJobWorkflowIdMapping'
|
||||
)
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [{ ...createRunningJob(1, 'run-1'), workflow_id: 'wf-1' }],
|
||||
Pending: []
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(registerSpy).toHaveBeenCalledWith('run-1', 'wf-1')
|
||||
})
|
||||
|
||||
it('should load history tasks from API', async () => {
|
||||
const historyJob1 = createHistoryJob(5, 'hist-1')
|
||||
const historyJob2 = createHistoryJob(4, 'hist-2')
|
||||
@@ -1255,43 +1115,3 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueuePendingTaskCountStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates from status websocket messages', () => {
|
||||
const store = useQueuePendingTaskCountStore()
|
||||
|
||||
store.update(
|
||||
fromAny<CustomEvent, unknown>({
|
||||
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<CustomEvent, unknown>({}))
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -273,6 +273,9 @@ export class TaskItemImpl {
|
||||
}
|
||||
|
||||
calculateFlatOutputs(): ReadonlyArray<ResultItemImpl> {
|
||||
if (!this.outputs) {
|
||||
return []
|
||||
}
|
||||
return parseTaskOutput(this.outputs)
|
||||
}
|
||||
|
||||
@@ -432,6 +435,9 @@ 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)
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import { 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 }))
|
||||
|
||||
type JobStatus =
|
||||
| 'in_progress'
|
||||
| 'pending'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
function executionError(
|
||||
overrides: Partial<NonNullable<JobListItem['execution_error']>> = {}
|
||||
): NonNullable<JobListItem['execution_error']> {
|
||||
return {
|
||||
node_id: '1',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'boom',
|
||||
exception_type: 'Error',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function job(over: Partial<JobListItem> = {}): 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'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
@@ -154,22 +154,6 @@ describe(parseNodeOutput, () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('valid.png')
|
||||
})
|
||||
|
||||
it('excludes non-object and invalid-type items', () => {
|
||||
const output = fromAny<NodeExecutionOutput, unknown>({
|
||||
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, () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
|
||||
@@ -103,24 +102,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
function makeSubgraph(id: string): Subgraph {
|
||||
return fromPartial<Subgraph>({
|
||||
id,
|
||||
isRootGraph: false,
|
||||
rootGraph: app.rootGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
})
|
||||
}
|
||||
|
||||
async function makeDuplicatedNavigationFailure(): Promise<Error> {
|
||||
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()
|
||||
@@ -131,7 +118,6 @@ 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
|
||||
@@ -244,42 +230,6 @@ 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 = [
|
||||
@@ -354,196 +304,4 @@ 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<LGraph>({ 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<LGraph>({ 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<LGraph>({})
|
||||
|
||||
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<LGraph>({ 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<LGraph>({ 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<ComfyWorkflow>({ 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<ComfyWorkflow>({
|
||||
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<ComfyWorkflow>({
|
||||
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<ComfyWorkflow>({
|
||||
path: 'root-workflow.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
|
||||
app.rootGraph.id = ids.deletedSubgraph
|
||||
app.canvas.graph = fromPartial<LGraph>({ 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<ComfyWorkflow>({
|
||||
path: 'already-active.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
|
||||
app.rootGraph.id = ids.deletedSubgraph
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.deletedSubgraph })
|
||||
})
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
|
||||
)
|
||||
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalledWith(app.rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +126,7 @@ 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]
|
||||
@@ -169,8 +170,7 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
if (!isWorkflowSwitching) {
|
||||
if (prevSubgraph) {
|
||||
saveViewport(prevSubgraph.id)
|
||||
}
|
||||
if (!prevSubgraph && subgraph) {
|
||||
} else if (!prevSubgraph && subgraph) {
|
||||
saveViewport(getCurrentRootGraphId())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -137,20 +136,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
})
|
||||
|
||||
describe('saveViewport', () => {
|
||||
it('does not save when canvas is unavailable', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as 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
|
||||
@@ -179,36 +164,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
})
|
||||
|
||||
describe('restoreViewport', () => {
|
||||
it('does nothing when canvas is unavailable', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as 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] })
|
||||
@@ -311,10 +266,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// User navigated away before the inner RAF fired
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({
|
||||
id: 'different-graph',
|
||||
isRootGraph: false
|
||||
})
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
rafCallbacks[1](performance.now())
|
||||
|
||||
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
|
||||
@@ -331,10 +283,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
// Simulate graph switching away before rAF fires
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({
|
||||
id: 'different-graph',
|
||||
isRootGraph: false
|
||||
})
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
@@ -392,23 +341,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockCanvas.ds.offset).toEqual([100, 100])
|
||||
})
|
||||
|
||||
it('does not save the outgoing viewport while a workflow switch is blocked', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const subgraph = fromPartial<Subgraph>({
|
||||
id: 'sub1',
|
||||
isRootGraph: false,
|
||||
rootGraph: app.rootGraph
|
||||
})
|
||||
|
||||
store.saveCurrentViewport()
|
||||
store.viewportCache.clear()
|
||||
workflowStore.activeSubgraph = subgraph
|
||||
await nextTick()
|
||||
|
||||
expect(store.viewportCache.has(':root')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves pre-existing cache entries across workflow switches', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -10,13 +10,9 @@ 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 { 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'
|
||||
@@ -40,7 +36,6 @@ vi.mock('@/scripts/api', () => ({
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
getGlobalSubgraphs: vi.fn(),
|
||||
deleteUserData: vi.fn(),
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
@@ -103,12 +98,6 @@ describe('useSubgraphStore', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useSubgraphStore()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm: vi.fn(() => true)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow publishing of a subgraph', async () => {
|
||||
@@ -145,86 +134,6 @@ 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<ExportedSubgraph, unknown>(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<ExportedSubgraph, unknown>(subgraph.serialize())]
|
||||
}))
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
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<ExportedSubgraph, unknown>(subgraph.serialize())]
|
||||
}))
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
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(
|
||||
@@ -239,30 +148,6 @@ 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<typeof comfyApp.canvas.graph>,
|
||||
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 })
|
||||
@@ -281,12 +166,6 @@ 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)
|
||||
@@ -309,57 +188,6 @@ 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()
|
||||
})
|
||||
|
||||
it('does not delete when confirmation is cancelled', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm: vi.fn(() => false)
|
||||
})
|
||||
)
|
||||
|
||||
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
|
||||
|
||||
expect(api.deleteUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 })
|
||||
@@ -457,203 +285,6 @@ 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 () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
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(consoleSpy).toHaveBeenCalledTimes(4)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
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)
|
||||
)
|
||||
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<ComfyWorkflowJSON, unknown>({
|
||||
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<ComfyWorkflowJSON, unknown>({
|
||||
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<ReturnType<typeof useDialogService>>({
|
||||
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<ReturnType<typeof useDialogService>>({
|
||||
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(
|
||||
@@ -775,29 +406,6 @@ 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)
|
||||
@@ -807,8 +415,7 @@ describe('useSubgraphStore', () => {
|
||||
// Set metadata on the subgraph's extra (as the commands do)
|
||||
subgraph.extra = {
|
||||
BlueprintDescription: 'Test description',
|
||||
BlueprintSearchAliases: ['alias1', 'alias2'],
|
||||
workflowRendererVersion: 'Vue'
|
||||
BlueprintSearchAliases: ['alias1', 'alias2']
|
||||
}
|
||||
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
@@ -857,7 +464,6 @@ describe('useSubgraphStore', () => {
|
||||
const subgraphExtra = definitions.subgraphs[0]?.extra
|
||||
expect(subgraphExtra?.BlueprintDescription).toBeUndefined()
|
||||
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
|
||||
expect(subgraphExtra?.workflowRendererVersion).toBe('Vue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -39,10 +39,6 @@ async function confirmOverwrite(name: string): Promise<boolean | null> {
|
||||
})
|
||||
}
|
||||
|
||||
type ValidSubgraphWorkflowJSON = ComfyWorkflowJSON & {
|
||||
definitions: NonNullable<ComfyWorkflowJSON['definitions']>
|
||||
}
|
||||
|
||||
export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
class SubgraphBlueprint extends ComfyWorkflow {
|
||||
static override readonly basePath = 'subgraphs/'
|
||||
@@ -58,20 +54,18 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
this.hasPromptedSave = !confirmFirstSave
|
||||
}
|
||||
|
||||
validateSubgraph(): ValidSubgraphWorkflowJSON {
|
||||
const activeState = this.activeState
|
||||
if (!activeState?.definitions)
|
||||
validateSubgraph() {
|
||||
if (!this.activeState?.definitions)
|
||||
throw new Error(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
)
|
||||
const validState = activeState as ValidSubgraphWorkflowJSON
|
||||
const { subgraphs } = validState.definitions
|
||||
const { nodes } = validState
|
||||
const { subgraphs } = this.activeState.definitions
|
||||
const { nodes } = this.activeState
|
||||
//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 validState
|
||||
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return
|
||||
const errors: Record<SerializedNodeId, NodeError> = {}
|
||||
//mark errors for all but first subgraph node
|
||||
let firstSubgraphFound = false
|
||||
@@ -94,7 +88,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
}
|
||||
|
||||
override async save(): Promise<UserFile> {
|
||||
const activeState = this.validateSubgraph()
|
||||
this.validateSubgraph()
|
||||
if (
|
||||
!this.hasPromptedSave &&
|
||||
useSettingStore().get('Comfy.Workflow.WarnBlueprintOverwrite')
|
||||
@@ -103,7 +97,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
this.hasPromptedSave = true
|
||||
}
|
||||
// Extract metadata from subgraph.extra to workflow.extra before saving
|
||||
this.extractMetadataToWorkflowExtra(activeState)
|
||||
this.extractMetadataToWorkflowExtra()
|
||||
const ret = await super.save()
|
||||
// Force reload to update initialState with saved metadata
|
||||
registerNodeDef(await this.load({ force: true }), {
|
||||
@@ -116,14 +110,13 @@ 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(
|
||||
activeState: ValidSubgraphWorkflowJSON
|
||||
): void {
|
||||
const subgraph = activeState.definitions.subgraphs?.[0]
|
||||
private extractMetadataToWorkflowExtra(): void {
|
||||
if (!this.activeState) return
|
||||
const subgraph = this.activeState.definitions?.subgraphs?.[0]
|
||||
if (!subgraph?.extra) return
|
||||
|
||||
const sgExtra = subgraph.extra as Record<string, unknown>
|
||||
const workflowExtra = (activeState.extra ??= {}) as Record<
|
||||
const workflowExtra = (this.activeState.extra ??= {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
@@ -136,10 +129,10 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
}
|
||||
|
||||
override async saveAs(path: string) {
|
||||
const activeState = this.validateSubgraph()
|
||||
this.validateSubgraph()
|
||||
this.hasPromptedSave = true
|
||||
// Extract metadata from subgraph.extra to workflow.extra before saving
|
||||
this.extractMetadataToWorkflowExtra(activeState)
|
||||
this.extractMetadataToWorkflowExtra()
|
||||
const ret = await super.saveAs(path)
|
||||
// Force reload to update initialState with saved metadata
|
||||
registerNodeDef(await this.load({ force: true }), {
|
||||
@@ -283,8 +276,8 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
overrides: Partial<ComfyNodeDefV1> = {},
|
||||
name: string = workflow.filename
|
||||
) {
|
||||
const subgraphNode = workflow.changeTracker.initialState
|
||||
.nodes[0] as ComfyNode
|
||||
const subgraphNode = workflow.changeTracker.initialState.nodes[0]
|
||||
if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint')
|
||||
subgraphNode.inputs ??= []
|
||||
subgraphNode.outputs ??= []
|
||||
//NOTE: Types are cast to string. This is only used for input coloring on previews
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isCloud: false, isDesktop: false }))
|
||||
const mockData = vi.hoisted(() => ({ isDesktop: false }))
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -19,9 +19,7 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return mockData.isDesktop
|
||||
},
|
||||
get isCloud() {
|
||||
return mockData.isCloud
|
||||
}
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
describe('useSystemStatsStore', () => {
|
||||
@@ -140,7 +138,6 @@ describe('useSystemStatsStore', () => {
|
||||
describe('getFormFactor', () => {
|
||||
beforeEach(() => {
|
||||
// Reset systemStats for each test
|
||||
mockData.isCloud = false
|
||||
store.systemStats = null
|
||||
})
|
||||
|
||||
@@ -165,12 +162,6 @@ 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
|
||||
|
||||
@@ -90,12 +90,6 @@ 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
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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' }])
|
||||
})
|
||||
})
|
||||
@@ -116,33 +116,6 @@ 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', () => {
|
||||
@@ -175,60 +148,6 @@ 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', () => {
|
||||
@@ -242,26 +161,6 @@ 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', () => {
|
||||
@@ -282,41 +181,6 @@ 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', () => {
|
||||
@@ -343,25 +207,6 @@ 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,72 +1,61 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUserStore } from './userStore'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
createUser: vi.fn(),
|
||||
getUserConfig: vi.fn(),
|
||||
user: undefined as string | undefined
|
||||
}))
|
||||
const getUserConfig = vi.fn()
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
api: {
|
||||
getUserConfig: (...args: unknown[]) => getUserConfig(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('userStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
apiMock.createUser.mockReset()
|
||||
apiMock.getUserConfig.mockReset()
|
||||
apiMock.user = undefined
|
||||
setActivePinia(createPinia())
|
||||
getUserConfig.mockReset()
|
||||
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 () => {
|
||||
apiMock.getUserConfig.mockResolvedValue({})
|
||||
getUserConfig.mockResolvedValue({})
|
||||
const store = useUserStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(store.initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op once already initialized', async () => {
|
||||
apiMock.getUserConfig.mockResolvedValue({})
|
||||
getUserConfig.mockResolvedValue({})
|
||||
const store = useUserStore()
|
||||
await store.initialize()
|
||||
apiMock.getUserConfig.mockClear()
|
||||
getUserConfig.mockClear()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(apiMock.getUserConfig).not.toHaveBeenCalled()
|
||||
expect(getUserConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries on a subsequent call when the first fetch failed', async () => {
|
||||
apiMock.getUserConfig.mockRejectedValueOnce(new Error('network down'))
|
||||
apiMock.getUserConfig.mockResolvedValueOnce({})
|
||||
getUserConfig.mockRejectedValueOnce(new Error('network down'))
|
||||
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(apiMock.getUserConfig).toHaveBeenCalledTimes(2)
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(2)
|
||||
expect(store.initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent calls before the first fetch resolves', async () => {
|
||||
let resolveConfig: (value: unknown) => void = () => {}
|
||||
apiMock.getUserConfig.mockImplementation(
|
||||
getUserConfig.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveConfig = resolve
|
||||
@@ -79,100 +68,7 @@ describe('userStore', () => {
|
||||
resolveConfig({})
|
||||
await Promise.all([a, b])
|
||||
|
||||
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()
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
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<string, unknown> } | null,
|
||||
nodes: {} as Record<string, unknown>,
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FuseSearchable } from '@/utils/fuseUtil'
|
||||
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
|
||||
|
||||
interface SearchItem extends Partial<FuseSearchable> {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface FilterItem {
|
||||
options: string[]
|
||||
}
|
||||
|
||||
const makeSearch = <T>(data: T[] = []) =>
|
||||
new FuseSearch<T>(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<string>([], {})
|
||||
|
||||
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<SearchItem>()
|
||||
|
||||
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<SearchItem>()
|
||||
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<string>([], {})
|
||||
|
||||
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<FilterItem, string>([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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,14 @@
|
||||
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 {
|
||||
addToComboValues,
|
||||
compressWidgetInputSlots,
|
||||
createNode,
|
||||
executeWidgetsCallback,
|
||||
getItemsColorOption,
|
||||
getLinkTypeColor,
|
||||
getWidgetIdForNode,
|
||||
isAnimatedOutput,
|
||||
isAudioNode,
|
||||
isImageNode,
|
||||
isLoad3dNode,
|
||||
isVideoNode,
|
||||
isVideoOutput,
|
||||
migrateWidgetsValues,
|
||||
resolveComboValues,
|
||||
resolveNode,
|
||||
resolveNodeWidget
|
||||
} from './litegraphUtil'
|
||||
import { createNode, getWidgetIdForNode, resolveNode } from './litegraphUtil'
|
||||
|
||||
const mockBringNodeToFront = vi.fn()
|
||||
|
||||
@@ -216,233 +191,3 @@ 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<IComboWidget>({
|
||||
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<IComboWidget>({ 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<IBaseWidget>({ onRemove }),
|
||||
fromPartial<IBaseWidget>({ 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<IBaseWidget>({ name: 'mode' }),
|
||||
fromPartial<IBaseWidget>({ 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<ISerialisedGraph>({
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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> = {}): RegistryNode {
|
||||
return over as RegistryNode
|
||||
}
|
||||
|
||||
function pack(over: Partial<RegistryPack> = {}): 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(Object.keys(def.inputs)).toEqual(['seed', 'label'])
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
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]])
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
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', () => {
|
||||
expect(
|
||||
resolveNodeDisplayName({ title: '', type: 'CLIP Text Encode' }, options)
|
||||
).toBe('nodeDefs.CLIP Text Encode.display_name:CLIP Text Encode')
|
||||
})
|
||||
|
||||
it('falls back to the untitled label when title and type are empty', () => {
|
||||
expect(resolveNodeDisplayName({ title: '', type: '' }, options)).toBe(
|
||||
'nodeDefs.Untitled Node.display_name:Untitled Node'
|
||||
)
|
||||
expect(resolveNodeDisplayName({}, options)).toBe(
|
||||
'nodeDefs.Untitled Node.display_name:Untitled Node'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
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).toHaveBeenCalledWith('blob:unknown')
|
||||
})
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
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<typeof buildJobDisplay>[0]
|
||||
type PreviewOutput = NonNullable<QueueDisplayTask['previewOutput']>
|
||||
|
||||
function createJob(
|
||||
status: JobListItem['status'],
|
||||
overrides: Partial<JobListItem> = {}
|
||||
): JobListItem {
|
||||
return {
|
||||
id: 'job-123456',
|
||||
status,
|
||||
create_time: 1_710_000_000_000,
|
||||
priority: 12,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createTask(
|
||||
options: {
|
||||
job?: Partial<JobListItem>
|
||||
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> = {}
|
||||
): 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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createRafBatch } from './rafBatch'
|
||||
|
||||
describe('createRafBatch', () => {
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import {
|
||||
buildTree,
|
||||
combineTrees,
|
||||
findNodeByKey,
|
||||
flattenTree,
|
||||
sortedTree,
|
||||
unwrapTreeRoot
|
||||
} from '@/utils/treeUtil'
|
||||
|
||||
const createNode = (label: string, leaf = false): TreeNode => ({
|
||||
key: label,
|
||||
label,
|
||||
leaf,
|
||||
children: []
|
||||
})
|
||||
import { buildTree, sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
describe('buildTree', () => {
|
||||
it('should handle empty folder items correctly', () => {
|
||||
@@ -79,101 +65,14 @@ describe('buildTree', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const createNode = (label: string, leaf = false): TreeNode => ({
|
||||
key: label,
|
||||
label,
|
||||
leaf,
|
||||
children: []
|
||||
})
|
||||
|
||||
it('should return a new node instance', () => {
|
||||
const node = createNode('root')
|
||||
const result = sortedTree(node)
|
||||
@@ -193,25 +92,6 @@ 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 = {
|
||||
@@ -230,35 +110,6 @@ 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',
|
||||
@@ -294,54 +145,3 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,7 +148,7 @@ function cloneTree<T extends TreeNode>(node: T): T {
|
||||
const clone = { ...node }
|
||||
|
||||
// Clone children recursively
|
||||
if (node.children) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
clone.children = node.children.map((child) => cloneTree(child))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user