Compare commits

...

48 Commits

Author SHA1 Message Date
huang47
f73fb530b2 ci: skip website report deploy for fork PRs 2026-06-27 20:28:22 -07:00
huang47
dcc5e53ea9 test: improve store branch coverage 2026-06-27 20:17:42 -07:00
huang47
0968b072eb test: raise store branch coverage to 95 2026-06-27 14:05:15 -07:00
huang47
a52e01577c test: ratchet critical branch coverage to 90 2026-06-27 12:06:00 -07:00
huang47
f220faf41d test: cover utility branch gaps 2026-06-27 11:34:22 -07:00
huang47
e390ff3b77 test: cover small utility branches 2026-06-27 11:17:41 -07:00
huang47
4902c88022 test: cover node badge branches 2026-06-27 11:11:11 -07:00
huang47
0deac2235f test: extend critical branch coverage 2026-06-27 10:57:20 -07:00
huang47
71f2d2ce4c test: clean up critical coverage imports 2026-06-27 10:28:09 -07:00
huang47
9bc18de623 test: cover workflow store node locator translation
Add behavioral coverage for useWorkflowStore node-identification helpers:
nodeIdToNodeLocatorId (root-graph bare id vs explicit subgraph uuid
prefix), nodeToNodeLocatorId from a node's graph, nodeLocatorIdToNodeId
extraction, root round-trip, and nodeLocatorIdToNodeExecutionId for a
root locator. Uses the real createNodeLocatorId/parseNodeLocatorId utils.
Third stacked workflowStore slice. Stage 1-4 of the test coverage ratchet.
2026-06-27 10:19:31 -07:00
huang47
7ba60a6d90 test: cover execution store interrupt and cached events
Add behavioral coverage for useExecutionStore lifecycle events: an
interruption drops the workflow badge and returns to idle, an executing
event resolving to null ends the active job, and execution_cached marks
the cached nodes as executed. Reuses the execution-store harness. Fifth
stacked executionStore slice. Stage 1-4 of the test coverage ratchet.
2026-06-27 10:19:31 -07:00
huang47
775ef2d7cd test: cover workflow store list selectors
Add behavioral coverage for useWorkflowStore list computeds:
persistedWorkflows (excludes unpersisted and subgraphs/ entries),
modifiedWorkflows (only modified), bookmarkedWorkflows (empty when none
bookmarked), and openedWorkflowIndexShift returning null with no active
workflow. Flagged plain-object workflows drive the filters. Stacks with
the tab-management PR on workflowStore. Stage 1-4 of the test coverage
ratchet.
2026-06-27 10:19:31 -07:00
huang47
2f7ab56c90 test: cover workflow store tab management
Add behavioral coverage for useWorkflowStore's synchronous tab/open-list
management: attachWorkflow + getWorkflowByPath lookup, open vs attached-
but-closed tracking, reorderWorkflows tab reordering, openWorkflowsInBackground
side placement with invalid-path filtering, and the no-active-workflow
case. Plain-object workflows drive the path-keyed state. Raises workflowStore
from 0% to ~8% branch (sync slice of a large async-I/O store). Stage 1-4 of
the test coverage ratchet (workflow persistence area).
2026-06-27 10:19:31 -07:00
huang47
15388a830b test: cover execution store progress lifecycle
Add behavioral coverage for useExecutionStore execution progress counts:
zero progress while idle, queued-node count once a job starts, progress
advancing as executed events arrive (nodesExecuted / executionProgress),
and executed events ignored without an active job. Drives storeJob plus
the real execution_start/executed handlers. Adds ~12% branch to
executionStore (lifecycle aspect; stacks with the status/progress/error
PRs). Stage 1-4 of the test coverage ratchet.
2026-06-27 10:19:31 -07:00
huang47
72a27a8d7a test: cover execution store error routing
Add behavioral coverage for useExecutionStore error handling: a node
execution error marks the workflow failed and records the raw error;
account-precondition errors route away from the failed badge; node-less
service-level errors become formatted prompt errors; and cloud validation
errors are classified into node errors without a failed badge. Drives the
real execution_error handler via captured api listeners. Adds ~19% branch
to executionStore (error aspect; stacks with the status and progress PRs).
Stage 1-4 of the test coverage ratchet.
2026-06-27 10:19:31 -07:00
huang47
97de914dcc test: cover execution store node progress
Add behavioral coverage for useExecutionStore node progress: idle until
execution_start, running-node-id derivation from progress_state events,
fractional executing-node progress, no-running-node case, and progress
state replacement across successive events. Drives the real progress_state
and execution_start handlers via captured api listeners. Adds ~11% branch
to executionStore (progress aspect; stacks with the status PR). Stage 1-4
of the test coverage ratchet.
2026-06-27 10:19:31 -07:00
huang47
eb41dd87ba test: cover execution store workflow status state machine
Add behavioral coverage for useExecutionStore's per-workflow status: an
open workflow goes running on execution_start and completed on
execution_success; a status arriving before storeJob is buffered then
flushed on attach; status is not applied to a closed workflow; clear
removes it; and a late buffered running cannot overwrite a terminal
status. Drives the real api event handlers and storeJob/getWorkflowStatus
rather than internals. Adds ~16% branch to executionStore (the status
machine; progress/error handlers remain follow-ups). Stage 1-4 of the
test coverage ratchet (workflow execution critical area).
2026-06-27 10:19:31 -07:00
huang47
291a23b742 test: ratchet critical branch coverage 2026-06-27 10:17:24 -07:00
huang47
cbb9609753 test: cover queue display formatting 2026-06-27 09:47:00 -07:00
huang47
bc994fc51d test: cover fuse search ranking 2026-06-27 09:47:00 -07:00
huang47
08a2ef0f60 test: cover group-context menu assembly
Extend useMoreOptionsMenu coverage to the single-group selection path
using a real LGraphGroup: the menu assembles group mode, fit, and group
color sections. Raises the orchestrator from ~62% to ~73% branch.
2026-06-27 09:47:00 -07:00
huang47
a100779d75 test: cover more-options menu orchestration
Add behavioral coverage for useMoreOptionsMenu: the node-options popover
singleton (register/isOpen/toggle/show forwarding and no-op when
unregistered) and the menu assembly for single-node and multi-node
selections (basic ops, pin, multiple-node options) plus the version bump.
Sub-composables and selection state are mocked (first-party). Raises the
orchestrator 0% -> ~62% branch (group/image/widget paths are follow-ups).
Stage 1-4 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
90ddc49cbd test: cover group menu options
Add behavioral coverage for useGroupMenuOptions: fit-group-to-nodes
(resize with configured padding + dirty/capture) and its recompute-throws
abort, shape and color submenu actions (dark theme), empty-group mode
options, and the per-current-mode option sets (ALWAYS/NEVER/BYPASS/mixed)
with mode application. First-party mocks; fake group/nodes. Raises the
composable 0% -> ~73% branch. Stage 1-4 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
d5914b1dc8 test: cover node shape apply/current paths
Extend useNodeCustomization coverage to the instanceof-LGraphNode shape
paths using a real LGraphNode: applyShape sets the node shape and
refreshes, and getCurrentShape reports a selected node's shape. Raises
the composable from ~69% to ~81% branch.
2026-06-27 09:47:00 -07:00
huang47
8e98e2027b test: cover graph node customization
Add behavioral coverage for useNodeCustomization: color/shape option lists,
light-theme flag, applyColor clearing (no-color) and applying a named color
to colorable items with canvas refresh, getCurrentColor empty/unrecognized
fallback, and the no-node-selected guards for applyShape/getCurrentShape.
Colorable items are duck-typed (isColorable); the instanceof-LGraphNode
shape-apply path is a follow-up. Raises the composable 0% -> ~69% branch.
Stage 1-4 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
f1866cd45e test: cover single-node rename via title editor
Extend useSelectionOperations coverage to the instanceof-LGraphNode
rename branch: a single selected node routes to the title editor instead
of the prompt dialog. Raises the composable from ~80% to ~83% branch.
2026-06-27 09:47:00 -07:00
huang47
cd0e3ca2f1 test: cover graph selection operations
Add behavioral coverage for useSelectionOperations: copy/duplicate/delete
empty-selection warnings and their non-empty canvas actions (copyToClipboard,
deleteSelected, setDirty), paste with change capture, single non-node rename
via the prompt dialog, indexed batch rename, and the empty-selection rename
warning. Mocks the app canvas and stores (all first-party). Raises the
composable from 0% to ~80% branch. Stage 1-4 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
94c084a2b4 test: cover workflow templates store loading and grouping
Add behavioral coverage for useWorkflowTemplatesStore: loadWorkflowTemplates
populates core templates and the known-name index, getTemplateByName lookup
(hit and miss), groupedTemplates exposes the loaded templates, the
isLoaded guard prevents refetch, and getEnglishMetadata returns null with
no english templates. Mocks api/i18n/distribution; drives the real load
and grouping. Raises workflowTemplatesStore 0% -> ~18% branch (no
pre-existing test). Stage 1-4 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
2871fc81ff test: cover exported trackNodePrice dependency tracking
Test trackNodePrice directly: the no-dynamic-pricing early return and the
widget/input/input-group reactive dependency loops for dynamic-priced
nodes. Raises usePartitionedBadges from ~48% to ~65% branch (the subgraph
inner-node walk remains, needing a real SubgraphNode wrapper).
2026-06-27 09:47:00 -07:00
huang47
0714737bc5 test: cover api-node dynamic pricing dependency tracking
Extend usePartitionedBadges coverage to the apiNode dynamic-pricing path:
relevant widget values, regular input links, and input-group prefixes are
accessed to establish reactive dependencies. Raises the composable from
~27% to ~48% branch.
2026-06-27 09:47:00 -07:00
huang47
6c11073057 test: cover vue-node badge partitioning
Add behavioral coverage for usePartitionedBadges core/extension/pricing
partitioning: no core badges when all modes are None, id badge, lifecycle
badge (bracket-trimmed), source badge for non-core nodes, extension-vs-
credits split (skipping the first badge), and hasComfyBadge for a core
node with source ShowAll and no pricing. Raises the composable to ~27%
branch (no pre-existing test); the dynamic-pricing path is a follow-up.
Stage 3-4 of the test coverage ratchet (renderer/vue-nodes).
2026-06-27 09:47:00 -07:00
huang47
c0a3656c8f test: cover execution store running-state and error edges
Target previously-uncovered branches in useExecutionStore (verified
marginal over the existing executionStore.test.ts, 67.6% -> 74.5% branch):
runningJobIds / runningWorkflowCount from progress_state, isActiveWorkflowRunning
job+path+session agreement, the service-level error message formed from
exception_message alone (no exception_type), and the cloud promptError
classification branch. Genuinely additive Stage 2-4 climb on executionStore.
2026-06-27 09:47:00 -07:00
huang47
f713704c11 test: cover node pack install composable
Add behavioral coverage for usePackInstall: isInstalling across idle/
installing/empty pack lists, performInstallation building install payloads
(latest_version vs unclaimed->nightly) and clearing the command, installAllPacks
installing only not-yet-installed packs, and the conflict path opening the
node-conflict dialog instead of installing. Raises usePackInstall from 0%
to ~65% branch. Stage 3-4 of the test coverage ratchet (extension manager).
2026-06-27 09:47:00 -07:00
huang47
6442655f23 test: cover manager display packs composable
Add behavioral coverage for useManagerDisplayPacks: per-tab displayPacks
routing (All/NotInstalled/AllInstalled/UpdateAvailable/Conflicting/
Workflow/Missing/Unresolved), the semver update-available filter
(newer/equal/invalid-nightly/uninstalled branches), conflict filtering,
search vs no-search paths, configured-field sorting, missingNodePacks,
loading-state scoping, and the data-fetch trigger predicates. Raises the
composable from 0% to ~79% branch. Stage 1 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
7ca36aa0e4 test: cover registry-to-frontend node def mapper
Add behavioral coverage for registryToFrontendV2NodeDef: output mapping
with name->type and is_list defaults, empty/absent return_types, required
and optional input transformation into keyed specs, empty/absent
input_types, and the name/category/python_module fallbacks vs explicit
values. Raises mapperUtil.ts from 0% to ~87% branch. Stage 1 of the test
coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
645a8391b3 test: cover workflow metadata file dispatcher
Add behavioral coverage for getWorkflowDataFromFile: routing by mime type
and extension to every format parser (png/avif/webp/mp3/ogg/flac/webm/
isobmff/svg/gltf/latent/json), the webp+flac workflow/prompt extraction
with capitalized-key fallback, both flac mime types, and the unrecognized
-file undefined path. Raises metadata/parser.ts from 0% to ~98% branch.
Stage 1 of the test coverage ratchet (data-loss-critical import path).
2026-06-27 09:47:00 -07:00
huang47
83c4838a91 test: cover queue TaskItemImpl task model
Add behavioral coverage for TaskItemImpl in queueStore: status -> taskType/
apiTaskType/displayStatus mapping, history/running flags and status-keyed
identity, flat-output handling (explicit, lazy parse, preview_output
synthesis), previewOutput saved-over-temp preference, interrupted
detection, error/passthrough fields, execution-time math, and flatten
(non-completed passthrough vs per-output expansion). Adds ~27% branch to
queueStore (TaskItemImpl portion). Stage 1 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
e8ffaf7961 test: cover queue ResultItemImpl output model
Add behavioral coverage for ResultItemImpl in queueStore: view/preview/
vhs url construction and the nameless-item guard, html video/audio mime
mapping by suffix and vhs format, media classification (image/video/
audio by suffix vs media type vs format), text/preview support, and the
filterPreviewable/findByUrl statics. Raises queueStore from 0% to ~37%
branch (ResultItemImpl portion); TaskItemImpl and useQueueStore remain
follow-ups. Stage 1 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
f749038ae3 test: cover about panel store
Add behavioral coverage for useAboutPanelStore badge assembly: core
badge github vs cloud url/icon, desktop electron-version label, the
templates badge presence and outdated -> danger severity branches, and
extension badge flatMap including the missing-aboutPageBadges fallback.
Raises this store from 0% to ~95% branch. Stage 1 of the test coverage
ratchet.
2026-06-27 09:47:00 -07:00
huang47
2342a2bf28 test: cover tree expansion composable
Add behavioral coverage for useTreeExpansion: toggleNode add/remove and
non-string-key guard, expandNode/collapseNode recursion over non-leaf
descendants (skipping leaves), toggleNodeRecursive both directions, and
toggleNodeOnEvent ctrl (recursive) vs plain (single) paths. Raises this
composable from 0% to ~89% branch. Stage 1 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
00e9d7ca90 test: cover asset export store
Add behavioral coverage for useAssetExportStore: trackExport idempotency,
active/finished status partitioning, websocket-driven state machine
(update, completed+downloaded short-circuit, export-name fallback),
triggerDownload success/guard/error-with-toast, clearFinishedExports,
and the stale-export reconnect poll (no-op, task-service reconcile,
error-swallow). Drives the real api event listener and interval callback
rather than private methods. Raises this store from 0% to ~85% branch.
Stage 1 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
4857b0ab72 test: cover node bookmark store
Add behavioral coverage for useNodeBookmarkStore: isBookmarked by path
or name, toggle add/remove, folder create (root and nested), the four
renameBookmarkFolder guard branches (non-folder, slash, collision,
valid rewrite), folder delete with descendants, and customization
default-stripping/rename. Raises this store from ~5% to ~92% branch
coverage. Stage 1 of the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
2e552f95cf test: cover favorited widgets store
Add behavioral coverage for useFavoritedWidgetsStore: add/remove/toggle
with the duplicate-guard and absent-entry no-op branches, resolve label
states (valid / node deleted / widget missing / graph not loaded),
prune, reorder, clear, and load-time normalization of legacy nodeId
entries. Raises this store from 0% to ~76% branch coverage. Stage 1 of
the test coverage ratchet.
2026-06-27 09:47:00 -07:00
huang47
28c7deb328 test: add cloud-critical browser lane
Add test:browser:cloud-critical (cloud project, --grep @critical) and tag
three stable cloud auth journeys @critical: unauthenticated redirect to
login, share-auth attribution preserved across redirect, and login page
sign-in options. Separates cloud/auth/billing risk from the default
Chromium lane (the @cloud tag keeps them out of it). Browser track PR 4
(Stage 6) of the test coverage plan.
2026-06-27 09:47:00 -07:00
huang47
7fcb0d66e4 test: extend @critical browser lane with subgraph and extension-compat journeys
Tag two more stable, functional journeys @critical: a subgraph
serialize-and-reload round-trip that asserts widget values survive
(high-risk data-loss path) and extension topbar-command registration via
the real extension API. Brings the critical lane to ~8 journeys across
boot/graph/search/queue/missing-deps/subgraph/extensions. Browser track
PR 3 of the test coverage plan.
2026-06-27 09:46:40 -07:00
huang47
be87266513 test: extend @critical browser lane with queue and missing-dependency journeys
Tag three more stable, functional (non-screenshot) journeys @critical:
queue overlay status (typed mocked jobs), missing-node pack guidance, and
missing-models group. Extends the PR-blocking browser contract into the
queue/progress and missing-dependency (data-loss-adjacent) critical areas
without adding new flake. Browser track PR 2 of the test coverage plan.
2026-06-27 09:46:40 -07:00
huang47
d276998a86 test: add @critical browser lane and tag stable journeys
Add test:browser:critical (chromium --grep @critical) and tag three
stable, functional (non-screenshot) journeys @critical: graph link
validation, execute-to-selected-output-nodes, and node search add.
Establishes the PR-blocking browser contract without new flake; CI
required-check wiring is a deliberate follow-up once the lane is proven
stable.
2026-06-27 09:46:40 -07:00
huang47
cb5ffa6483 test: add critical unit coverage gate
Add test:coverage:critical with a critical-scoped exclude list
(config, constants, storybook, views on top of the existing excludes)
and PR-blocking thresholds (statements 62.76 / branches 53.38 /
functions 57.30 / lines 63.98). Wire it into the unit CI job so the
current critical-coverage floor cannot regress. Emit json-summary for
machine-readable reporting.
2026-06-27 09:46:40 -07:00
104 changed files with 13094 additions and 319 deletions

View File

@@ -35,8 +35,8 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Run Vitest critical coverage gate
run: pnpm test:coverage:critical
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'

View File

@@ -67,7 +67,15 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -14,36 +14,44 @@ const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
* routes and elements.
*/
test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
test('cloud build redirects unauthenticated users to login', async ({
page
}) => {
await page.goto(APP_URL)
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
})
test(
'cloud build redirects unauthenticated users to login',
{ tag: '@critical' },
async ({ page }) => {
await page.goto(APP_URL)
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
}
)
test('preserves share auth attribution before redirecting logged-out users', async ({
page
}) => {
await page.goto(new URL('/?share=abc', APP_URL).toString())
test(
'preserves share auth attribution before redirecting logged-out users',
{ tag: '@critical' },
async ({ page }) => {
await page.goto(new URL('/?share=abc', APP_URL).toString())
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
await expect
.poll(() =>
page.evaluate(
(key) => sessionStorage.getItem(key),
SHARE_AUTH_STORAGE_KEY
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
await expect
.poll(() =>
page.evaluate(
(key) => sessionStorage.getItem(key),
SHARE_AUTH_STORAGE_KEY
)
)
)
.toBe(JSON.stringify({ share: 'abc' }))
})
.toBe(JSON.stringify({ share: 'abc' }))
}
)
test('cloud login page renders sign-in options', async ({ page }) => {
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
})
test(
'cloud login page renders sign-in options',
{ tag: '@critical' },
async ({ page }) => {
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
}
)
})

View File

@@ -97,34 +97,38 @@ test.describe(
'Execute to selected output nodes',
{ tag: ['@smoke', '@workflow'] },
() => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const input = await comfyPage.nodeOps.getNodeRefById(3)
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.toBe('')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.toBe('')
test(
'Execute to selected output nodes',
{ tag: '@critical' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const input = await comfyPage.nodeOps.getNodeRefById(3)
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.toBe('')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.toBe('')
await output1.click('title')
await 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('')
}
)
}
)

View File

@@ -13,33 +13,37 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
type TestSettingId = keyof Settings
test.describe('Topbar commands', () => {
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
window.foo = true
test(
'Should allow registering topbar commands',
{ tag: '@critical' },
async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
window.foo = true
}
}
}
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
})
})
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await expect
.poll(() => comfyPage.page.evaluate(() => window.foo))
.toBe(true)
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await expect
.poll(() => comfyPage.page.evaluate(() => window.foo))
.toBe(true)
}
)
test('Should not allow register command defined in other extension', async ({
comfyPage

View File

@@ -22,11 +22,15 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
.toBe(1)
})
test('Validate workflow links', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect(comfyPage.toast.visibleToasts).toHaveCount(2)
})
test(
'Validate workflow links',
{ tag: '@critical' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect(comfyPage.toast.visibleToasts).toHaveCount(2)
}
)
// Regression: duplicate links with shifted target_slot (widget-to-input
// conversion) caused the wrong link to survive during deduplication.

View File

@@ -8,20 +8,24 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.searchBoxV2.setup()
})
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
test(
'Can open search and add node',
{ tag: '@critical' },
async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
}
)
test('Can add first default result with Enter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -33,19 +33,21 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
test(
'Should show missing models group in errors tab',
{ tag: '@critical' },
async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
}
)
test('Should display model name and metadata', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')

View File

@@ -12,23 +12,25 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
})
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
test(
'Should show missing node pack card with guidance',
{ tag: '@critical' },
async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
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

View File

@@ -54,13 +54,19 @@ test.describe('Queue overlay', () => {
await comfyPage.setup()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
test(
'Toggle button opens expanded queue overlay',
{ tag: '@critical' },
async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
// Expanded overlay should show job items
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
})
// 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)

View File

@@ -129,7 +129,7 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test(
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
{ tag: ['@vue-nodes'] },
{ tag: ['@vue-nodes', '@critical'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'

View File

@@ -51,8 +51,11 @@
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec playwright test",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:critical": "pnpm exec playwright test --project=chromium --grep @critical",
"test:browser:cloud-critical": "pnpm exec playwright test --project=cloud --grep @critical",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -0,0 +1,75 @@
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)
})
})

View File

@@ -4,6 +4,7 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -43,4 +44,21 @@ 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)
})
})

View File

@@ -34,17 +34,22 @@ describe('useSelectionToolboxPosition', () => {
canvasStore = useCanvasStore()
})
function renderToolboxForSelection(item: Positionable) {
function renderToolboxForSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {},
ds: Partial<LGraphCanvas['ds']> = {}
) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
offset: ds.offset ?? [0, 0],
scale: ds.scale ?? 1
},
selectedItems: new Set([item]),
selectedItems: new Set(items),
state: {
draggingItems: false,
selectionChanged: true
selectionChanged: true,
...state
}
} as Partial<LGraphCanvas> as LGraphCanvas)
@@ -69,7 +74,7 @@ describe('useSelectionToolboxPosition', () => {
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection(group)
const { toolbox, unmount } = renderToolboxForSelection([group])
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
@@ -81,11 +86,64 @@ describe('useSelectionToolboxPosition', () => {
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection(node)
const { toolbox, unmount } = renderToolboxForSelection([node])
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('does not set coordinates when selection is empty', () => {
const { toolbox, unmount } = renderToolboxForSelection([])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not set coordinates while selected items are being dragged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group], {
draggingItems: true
})
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('positions multiple selected items from their union bounds', () => {
const first = new LGraphGroup('First', 1)
first.pos = [100, 200]
first.size = [100, 40]
const second = new LGraphGroup('Second', 2)
second.pos = [300, 260]
second.size = [50, 40]
const { toolbox, unmount } = renderToolboxForSelection([first, second])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
})
it('applies canvas scale and offset to screen coordinates', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [100, 40]
const { toolbox, unmount } = renderToolboxForSelection(
[group],
{},
{ offset: [10, 20], scale: 2 }
)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
unmount()
})
})

View File

@@ -0,0 +1,215 @@
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)
})
})

View File

@@ -1,6 +1,7 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
@@ -19,6 +20,11 @@ vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
@@ -27,6 +33,15 @@ 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 {
@@ -45,8 +60,13 @@ function createImageNode(
}
describe('useImageMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('getImageMenuOptions', () => {
@@ -182,4 +202,141 @@ 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()
})
})
})

View File

@@ -0,0 +1,292 @@
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')
})
})

View File

@@ -0,0 +1,175 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
selection: { items: [] as unknown[] },
refreshCanvas: vi.fn(),
palette: { light_theme: false }
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<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)
})
})

View File

@@ -10,30 +10,43 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
const { actions, customization } = vi.hoisted(() => ({
actions: {
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
},
customization: {
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
colorOptions: [] as Array<{
name: string
localizedName: string
value: { dark: string; light: string }
}>,
applyShape: vi.fn(),
applyColor: vi.fn(),
isLightTheme: { value: false }
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
useSelectedNodeActions: () => actions
}))
const i18n = createI18n({
@@ -69,9 +82,29 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
describe('useNodeMenuOptions.getBypassOption', () => {
function readNodeMenuOptions<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', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -97,4 +130,109 @@ describe('useNodeMenuOptions.getBypassOption', () => {
])
).toBe('contextMenu.Bypass')
})
it('labels visual node options from the collapsed state and bumps after action', () => {
const expandBump = vi.fn()
const expand = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
)
expect(expand).toMatchObject({
label: 'contextMenu.Expand Node',
icon: 'icon-[lucide--maximize-2]'
})
expand.action?.()
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
expect(expandBump).toHaveBeenCalledTimes(1)
const minimize = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
)
expect(minimize).toMatchObject({
label: 'contextMenu.Minimize Node',
icon: 'icon-[lucide--minimize-2]'
})
})
it('labels pin options from the pinned state and bumps after action', () => {
const bump = vi.fn()
const unpin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: true }, bump)
)
expect(unpin).toMatchObject({
label: 'contextMenu.Unpin',
icon: 'icon-[lucide--pin-off]'
})
unpin.action?.()
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
expect(bump).toHaveBeenCalledTimes(1)
const pin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: false }, vi.fn())
)
expect(pin).toMatchObject({
label: 'contextMenu.Pin',
icon: 'icon-[lucide--pin]'
})
})
it('builds shape and color submenus and applies selected values', () => {
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
customization.colorOptions = [
{
name: 'noColor',
localizedName: 'No Color',
value: { dark: '#000', light: '#fff' }
},
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
visualOptions: options.getNodeVisualOptions(
{ collapsed: false, pinned: false },
vi.fn()
),
colorSubmenu: options.colorSubmenu.value
}))
expect(visualOptions[1].submenu).toEqual([
expect.objectContaining({ label: 'Box' })
])
visualOptions[1].submenu?.[0].action()
expect(customization.applyShape).toHaveBeenCalledWith(
customization.shapeOptions[0]
)
expect(colorSubmenu).toEqual([
expect.objectContaining({ label: 'No Color', color: '#000' }),
expect.objectContaining({ label: 'Red', color: '#111' })
])
colorSubmenu[0].action()
colorSubmenu[1].action()
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
expect(customization.applyColor).toHaveBeenNthCalledWith(
2,
customization.colorOptions[1]
)
})
it('uses light-theme colors for the color submenu', () => {
customization.isLightTheme.value = true
customization.colorOptions = [
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
expect(
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
).toBe('#eee')
})
})

View File

@@ -0,0 +1,221 @@
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' })
)
})
})

View File

@@ -8,7 +8,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -17,7 +22,9 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -96,6 +103,14 @@ 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')
)
@@ -135,6 +150,21 @@ describe('useSelectionState', () => {
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 2 })
const group = createMockPositionable({ id: 2000 })
Object.assign(group, {
isGroup: true,
isNode: false,
children: new Set([graphNode])
})
canvasStore.$state.selectedItems = [group]
const { hasGroupedNodesSelection } = useSelectionState()
expect(hasGroupedNodesSelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
@@ -215,6 +245,13 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -0,0 +1,315 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick } from 'vue'
import type { App as VueApp } from 'vue'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const {
settings,
appState,
extensionState,
nodeDefState,
pricingState,
setDirtyMock,
addEventListenerMock,
registerExtensionMock,
getCreditsBadgeMock,
updateSubgraphCreditsMock,
getNodePricingConfigMock,
getNodeDisplayPriceMock,
getRelevantWidgetNamesMock,
triggerPriceRecalculationMock,
useComputedWithWidgetWatchMock
} = vi.hoisted(() => ({
settings: {} as Record<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])
})
})

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -12,6 +14,7 @@ import {
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
@@ -123,6 +126,35 @@ 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
// -----------------------------------------------------------------------------
@@ -189,6 +221,32 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.5))
})
it('should parse numeric strings and reject blank or invalid numbers', async () => {
const expression =
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
const parsedNode = createMockNodeWithPriceBadge(
'TestNumericStringNode',
badge,
[{ name: 'count', value: ' 5 ' }]
)
const blankNode = createMockNodeWithPriceBadge(
'TestBlankNumericStringNode',
badge,
[{ name: 'count', value: ' ' }]
)
const invalidNode = createMockNodeWithPriceBadge(
'TestInvalidNumericStringNode',
badge,
[{ name: 'count', value: 'five' }]
)
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
})
it('should handle COMBO widget with numeric value', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -222,6 +280,19 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should preserve boolean combo values', async () => {
const node = createMockNodeWithPriceBadge(
'TestComboBooleanNode',
priceBadge(
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
[{ name: 'enabled', type: 'COMBO' }]
),
[{ name: 'enabled', value: false }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
})
it('should handle BOOLEAN widget', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -238,6 +309,51 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should parse BOOLEAN widget string values', async () => {
const badge = priceBadge(
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
[{ name: 'premium', type: 'BOOLEAN' }]
)
const enabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringTrueNode',
badge,
[{ name: 'premium', value: ' TRUE ' }]
)
const disabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringFalseNode',
badge,
[{ name: 'premium', value: 'false' }]
)
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
})
it('should reject invalid BOOLEAN strings', async () => {
const node = createMockNodeWithPriceBadge(
'TestInvalidBooleanStringNode',
priceBadge(
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
[{ name: 'premium', type: 'BOOLEAN' }]
),
[{ name: 'premium', value: 'sometimes' }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should reject object values for numeric widgets', async () => {
const node = createMockNodeWithPriceBadge(
'TestObjectNumericNode',
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: { count: 5 } }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should handle STRING widget (lowercased)', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -468,6 +584,42 @@ describe('useNodePricing', () => {
})
})
describe('dependency context', () => {
it('should prefer widget overrides over node widget values', async () => {
const node = createMockNodeWithPriceBadge(
'TestWidgetOverrideNode',
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: 2 }]
)
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
expect(price).toBe(creditsLabel(0.07))
})
it('should treat missing input group arrays as zero connected inputs', async () => {
const node = Object.assign(createMockLGraphNode(), {
widgets: [],
constructor: {
nodeData: {
name: 'TestMissingInputGroupArrayNode',
api_node: true,
price_badge: priceBadge(
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
[],
[],
['images']
)
}
}
})
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
})
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -595,6 +747,86 @@ describe('useNodePricing', () => {
})
})
describe('node type pricing dependencies', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty dependency metadata for node types without pricing', () => {
const store = useNodeDefStore()
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
expect(getInputNames('UnpricedNode')).toEqual([])
})
it('dedupes dynamic pricing dependencies while preserving order', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'DynamicPricingNode',
priceBadge(
'{"type":"usd","usd":0.05}',
[
{ name: 'seed', type: 'INT' },
{ name: 'quality', type: 'COMBO' }
],
['image', 'seed'],
['clips', 'image']
)
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
'seed',
'quality',
'image',
'clips'
])
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
'clips',
'image'
])
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
})
it('handles fixed pricing metadata without dependencies', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'FixedPricingNode',
priceBadge('{"type":"usd","usd":0.05}')
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
expect(getInputNames('FixedPricingNode')).toEqual([])
})
})
describe('reactive revision', () => {
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
@@ -743,6 +975,16 @@ 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(
@@ -968,8 +1210,21 @@ describe('formatPricingResult', () => {
expect(result).toBe('~10.6')
})
it('should parse string usd values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'usd', usd: '0.05' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
const result = formatPricingResult({ type: 'usd', usd: null })
expect(result).toBe('')
})
it('should return empty for blank string usd', () => {
const result = formatPricingResult({ type: 'usd', usd: ' ' })
expect(result).toBe('')
})
})
@@ -999,6 +1254,14 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6')
})
it('should parse string range values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6-21.1')
})
})
describe('type: list_usd', () => {
@@ -1017,6 +1280,22 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6/21.1')
})
it('should return valueOnly format with approximate prefix', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6/21.1')
})
it('should return empty when list value is not an array', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: 'not-a-list'
})
expect(result).toBe('')
})
})
describe('type: text', () => {
@@ -1024,6 +1303,11 @@ describe('formatPricingResult', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
it('should return empty when text is missing', () => {
const result = formatPricingResult({ type: 'text' })
expect(result).toBe('')
})
})
describe('legacy format', () => {
@@ -1190,6 +1474,29 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use default value from optional input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'OptionalDefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {},
optional: {
count: ['INT', { default: 4 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('8.4')
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
@@ -1265,6 +1572,30 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should handle combo option arrays with primitive values', async () => {
const nodeDef = createMockNodeDef({
name: 'PrimitiveOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: ['COMBO', { options: ['fast', 'slow'] }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',

View File

@@ -0,0 +1,102 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import type { TreeNode } from '@/types/treeExplorerTypes'
function node(over: Partial<TreeNode>): 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 })
})
})

View File

@@ -1,16 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
import type { GroupNodeWorkflowData } from './groupNode'
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: vi.fn()
const appMock = vi.hoisted(() => ({
canvas: {
emitAfterChange: vi.fn(),
emitBeforeChange: vi.fn(),
selected_nodes: {}
},
registerExtension: vi.fn(),
registerNodeDef: vi.fn(),
rootGraph: {
convertToSubgraph: vi.fn(),
extra: {},
getNodeById: vi.fn(),
links: {},
nodes: [],
remove: vi.fn()
}
}))
const widgetStoreMock = vi.hoisted(() => ({
inputIsWidget: vi.fn((spec: unknown[]) =>
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
)
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => widgetStoreMock
}))
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
function makeNode(type: string): ComfyNode {
@@ -26,6 +58,42 @@ 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')]
@@ -104,4 +172,389 @@ 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'
})
)
})
})

View File

@@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
getSettingInfo,
@@ -11,31 +10,47 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
auth: { isLoggedIn: { value: false } },
billing: { isActiveSubscription: { value: false } },
dist: { isCloud: false, isDesktop: false },
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
vueFlags: { shouldRenderVueNodes: { value: false } }
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
useBillingContext: () => ({
isActiveSubscription: billing.isActiveSubscription
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
flags: featureFlags
})
}))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
useVueFeatureFlags: () => ({
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
})
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -49,6 +64,7 @@ interface MockSettingParams {
type: string
defaultValue: unknown
category?: string[]
hideInVueNodes?: boolean
}
describe('useSettingUI', () => {
@@ -72,13 +88,23 @@ describe('useSettingUI', () => {
defaultValue: 'dark'
}
}
let settingsById: Record<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: mockSettings
settingsById
} as ReturnType<typeof useSettingStore>)
vi.mocked(getSettingInfo).mockImplementation((setting) => {
@@ -107,9 +133,9 @@ describe('useSettingUI', () => {
undefined,
'Comfy.Locale'
)
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
expect(comfyCategory).toBeDefined()
expect(defaultCategory.value).toBe(comfyCategory)
expect(defaultCategory.value).toBe(
findCategory(settingCategories.value, 'Comfy')
)
})
it('resolves different category from scrollToSettingId', () => {
@@ -121,7 +147,6 @@ describe('useSettingUI', () => {
settingCategories.value,
'Appearance'
)
expect(appearanceCategory).toBeDefined()
expect(defaultCategory.value).toBe(appearanceCategory)
})
@@ -137,4 +162,82 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
it('falls back when defaultPanel is not in the menu', () => {
const missingPanel = 'missing' as unknown as Parameters<
typeof useSettingUI
>[0]
const { defaultCategory, settingCategories } = useSettingUI(missingPanel)
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('moves floating settings into Other and hides Vue-node-only settings', () => {
settingsById = {
Floating: {
id: 'Floating',
name: 'Floating',
type: 'boolean',
defaultValue: false
},
'Hidden.Setting': {
id: 'Hidden.Setting',
name: 'Hidden',
type: 'hidden',
defaultValue: false
},
'Vue.Hidden': {
id: 'Vue.Hidden',
name: 'Vue Hidden',
type: 'boolean',
defaultValue: false,
hideInVueNodes: true
}
}
vi.mocked(useSettingStore).mockReturnValue({
settingsById
} as ReturnType<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()
})
})

View File

@@ -0,0 +1,81 @@
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()
})
})

View File

@@ -0,0 +1,87 @@
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')
})
})

View File

@@ -0,0 +1,99 @@
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)
})
})

View File

@@ -0,0 +1,240 @@
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()
})
})

View File

@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import {
trackNodePrice,
usePartitionedBadges
} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const { settings, nodeDefs, pricing, getNodeRevisionRefMock, getWidgetMock } =
vi.hoisted(() => ({
settings: {} as Record<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)
})
})

View File

@@ -0,0 +1,127 @@
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)
})
})

View File

@@ -0,0 +1,135 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AboutPageBadge } from '@/types/comfy'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
interface SystemInfo {
comfyui_version?: string
installed_templates_version?: string
required_templates_version?: string
}
const { dist, stats, exts } = vi.hoisted(() => ({
dist: { isCloud: false, isDesktop: false },
stats: { system: {} as SystemInfo },
exts: { list: [] as { aboutPageBadges?: AboutPageBadge[] }[] }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: {
github: 'https://github.com/comfyanonymous/ComfyUI',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
comfyOrg: 'https://comfy.org',
discord: 'https://discord.com'
}
})
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => ({ getComfyUIVersion: () => '9.9.9' })
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({ extensions: exts.list })
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: () => ({ systemStats: stats })
}))
function label(badges: AboutPageBadge[], includes: string) {
return badges.find((b) => b.label.includes(includes))
}
beforeEach(() => {
setActivePinia(createPinia())
dist.isCloud = false
dist.isDesktop = false
stats.system = {}
exts.list = []
})
describe('aboutPanelStore', () => {
it('builds the default desktop-less, non-cloud core badges', () => {
stats.system = { comfyui_version: 'abc1234' }
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-github')
expect(core.url).toContain('github.com/comfyanonymous')
expect(label(store.badges, 'ComfyUI_frontend')).toBeDefined()
expect(label(store.badges, 'Discord')).toBeDefined()
expect(label(store.badges, 'Templates')).toBeUndefined()
})
it('uses cloud url and icon for the core badge when running on cloud', () => {
dist.isCloud = true
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-cloud')
expect(core.url).toBe('https://comfy.org')
})
it('uses the electron-reported version label on desktop', () => {
dist.isDesktop = true
const store = useAboutPanelStore()
expect(label(store.badges, 'ComfyUI v9.9.9')).toBeDefined()
})
it('adds a danger templates badge when the installed version is outdated', () => {
stats.system = {
installed_templates_version: '1.0.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.0.0')!
expect(templates.severity).toBe('danger')
})
it('adds a templates badge without severity when versions match', () => {
stats.system = {
installed_templates_version: '1.1.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('does not mark templates outdated when the required version is missing', () => {
stats.system = {
installed_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('appends extension badges and tolerates extensions without any', () => {
exts.list = [
{
aboutPageBadges: [{ label: 'My Ext', url: 'https://ext', icon: 'pi' }]
},
{} // extension without aboutPageBadges -> ?? [] branch
]
const store = useAboutPanelStore()
expect(label(store.badges, 'My Ext')).toBeDefined()
})
})

View File

@@ -0,0 +1,26 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useExtensionStore } from '@/stores/extensionStore'
describe('actionBarButtonStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('collects action bar buttons from registered extensions', () => {
const extensionStore = useExtensionStore()
const onClick = vi.fn()
extensionStore.registerExtension({
name: 'buttons',
actionBarButtons: [{ icon: 'icon-[lucide--plus]', onClick }]
})
extensionStore.registerExtension({ name: 'plain' })
const store = useActionBarButtonStore()
expect(store.buttons).toEqual([{ icon: 'icon-[lucide--plus]', onClick }])
})
})

View File

@@ -0,0 +1,133 @@
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)
)
})
})

View File

@@ -37,20 +37,6 @@ 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({
@@ -63,6 +49,20 @@ 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({

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { nextTick, reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -56,9 +56,13 @@ vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
resolveNode: mockResolveNode
}))
const mockCanvas = vi.hoisted(() => ({
state: undefined as { readOnly: boolean } | undefined
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => ({ read_only: false })
getCanvas: () => ({ state: mockCanvas.state })
})
}))
@@ -162,6 +166,7 @@ describe('appModeStore', () => {
ChangeTracker.isLoadingGraph = false
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
mockCanvas.state = undefined
vi.mocked(app.rootGraph).nodes = [{ id: toNodeId(1) } as LGraphNode]
workflowStore = useWorkflowStore()
store = useAppModeStore()
@@ -365,6 +370,83 @@ describe('appModeStore', () => {
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
})
it('keeps canonical entity ids when the node still exists', () => {
const node1 = nodeWithWidgets(1, [])
vi.mocked(app.rootGraph).nodes = [node1]
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
id === toNodeId(1) ? node1 : null
)
store.loadSelections({
inputs: [[entityPrompt, 'prompt']]
})
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
})
it('drops canonical entity ids when their node is gone', () => {
vi.mocked(app.rootGraph).nodes = []
vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null)
store.loadSelections({
inputs: [[entityPrompt, 'prompt']]
})
expect(store.selectedInputs).toEqual([])
})
it('drops locator inputs when the widget does not resolve', () => {
const hostLocator = `${rootGraphId}:5`
const hostNode = fromAny<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]
@@ -399,6 +481,32 @@ 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'])
@@ -481,7 +589,7 @@ describe('appModeStore', () => {
expect(
store.pruneLinearData({
inputs: [[1, 'seed']],
outputs: [toNodeId(1)]
outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')]
})
).toEqual({
inputs: [[1, 'seed']],
@@ -641,6 +749,17 @@ describe('appModeStore', () => {
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
})
it('does not write while graph loading is in progress', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
ChangeTracker.isLoadingGraph = true
await nextTick()
store.selectedOutputs.push(toNodeId(1))
await nextTick()
expect(app.rootGraph.extra.linearData).toBeUndefined()
})
it('calls captureCanvasState when input is selected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
@@ -755,6 +874,24 @@ describe('appModeStore', () => {
expect(store.selectedInputs).toEqual([[promptEntity, 'prompt']])
})
it('ignores widgets without ids', () => {
store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt'])
store.removeSelectedInput(fromAny<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', () => {
@@ -819,6 +956,47 @@ describe('appModeStore', () => {
expect.anything()
)
})
it('does not enable Vue nodes after leaving select mode', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
mockSettings.set.mockClear()
store.exitBuilder()
await nextTick()
expect(mockSettings.set).not.toHaveBeenCalled()
})
})
describe('read only canvas sync', () => {
it('keeps canvas read-only while in select mode', async () => {
mockCanvas.state = reactive({ readOnly: false })
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
mockCanvas.state.readOnly = false
await nextTick()
expect(mockCanvas.state.readOnly).toBe(true)
})
it('stops enforcing read-only after leaving select mode', async () => {
mockCanvas.state = reactive({ readOnly: false })
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
store.exitBuilder()
await nextTick()
mockCanvas.state.readOnly = true
await nextTick()
expect(mockCanvas.state.readOnly).toBe(true)
})
})
describe('legacy selectedInput tuple migration', () => {
@@ -907,6 +1085,121 @@ describe('appModeStore', () => {
])
})
it('drops direct root-node widgets that cannot produce an entity id', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const sourceNodeId = 42
const sourceWidgetName = 'text'
const rootNode = fromAny<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

View File

@@ -126,6 +126,19 @@ describe('useAssetDownloadStore', () => {
})
})
it('keeps the first placeholder when the same task is tracked twice', () => {
const store = useAssetDownloadStore()
store.trackDownload('task-123', 'checkpoints', 'first.safetensors')
store.trackDownload('task-123', 'loras', 'second.safetensors')
expect(store.downloadList).toHaveLength(1)
expect(store.downloadList[0]).toMatchObject({
modelType: 'checkpoints',
assetName: 'first.safetensors'
})
})
it('handles out-of-order messages where completed arrives before progress', () => {
const store = useAssetDownloadStore()
@@ -179,6 +192,19 @@ describe('useAssetDownloadStore', () => {
expect(store.finishedDownloads[0].status).toBe('completed')
})
it('skips polling when active downloads have fresh progress', async () => {
const store = useAssetDownloadStore()
dispatch(createDownloadMessage({ status: 'running' }))
await vi.advanceTimersByTimeAsync(9_999)
dispatch(createDownloadMessage({ status: 'running', progress: 75 }))
await vi.advanceTimersByTimeAsync(1)
expect(taskService.getTask).not.toHaveBeenCalled()
expect(store.activeDownloads).toHaveLength(1)
expect(store.activeDownloads[0].progress).toBe(75)
})
it('polls and marks failed downloads', async () => {
const store = useAssetDownloadStore()
@@ -311,5 +337,22 @@ describe('useAssetDownloadStore', () => {
expect(store.sessionDownloadCount).toBe(0)
expect(store.isDownloadedThisSession('asset-456')).toBe(false)
})
it('does not acknowledge unrelated completed downloads', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: 'asset-456'
})
)
store.acknowledgeAsset('other-asset')
expect(store.sessionDownloadCount).toBe(1)
expect(store.isDownloadedThisSession('asset-456')).toBe(true)
})
})
})

View File

@@ -0,0 +1,300 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
import type { AssetExportWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { TaskId } from '@/platform/tasks/services/taskService'
import { useAssetExportStore } from '@/stores/assetExportStore'
const { getExportDownloadUrl, getTask, toastAdd, intervalState } = vi.hoisted(
() => ({
getExportDownloadUrl: vi.fn(),
getTask: vi.fn(),
toastAdd: vi.fn(),
intervalState: { cb: null as null | (() => void) }
})
)
vi.mock('@vueuse/core', async (importOriginal) => ({
...(await importOriginal<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')
})
})

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
@@ -11,6 +12,7 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
// Mock the api module
vi.mock('@/scripts/api', () => ({
@@ -96,6 +98,10 @@ 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'])
@@ -169,11 +175,14 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
})),
mapTaskOutputToAssetItem: vi.fn((task, output) => {
const index = parseInt(task.jobId.split('_')[1]) || 0
const createdAt = new Date(Date.now() - index * 1000).toISOString()
return {
id: task.jobId,
name: output.filename,
size: 0,
created_at: new Date(Date.now() - index * 1000).toISOString(),
...(!mockAssetMapperOptions.omitCreatedAtForIds.has(task.jobId) && {
created_at: createdAt
}),
tags: ['output'],
preview_url: output.url,
user_metadata: {}
@@ -205,6 +214,7 @@ describe('assetsStore - Refactored (Option A)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAssetsStore()
vi.clearAllMocks()
mockAssetMapperOptions.omitCreatedAtForIds.clear()
})
describe('Initial Load', () => {
@@ -272,6 +282,17 @@ describe('assetsStore - Refactored (Option A)', () => {
'prompt_2'
])
})
it('should skip unfinished jobs and completed jobs without previews', async () => {
vi.mocked(api.getHistory).mockResolvedValue([
{ ...createMockJobItem(0), status: 'in_progress' },
{ ...createMockJobItem(1), preview_output: undefined }
])
await store.updateHistory()
expect(store.historyAssets).toEqual([])
})
})
describe('Pagination', () => {
@@ -328,6 +349,46 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(uniqueAssetIds.size).toBe(store.historyAssets.length)
})
it('should insert newer paginated items in sorted order', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('sorts paginated items when the incoming asset has no timestamp', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
mockAssetMapperOptions.omitCreatedAtForIds.add('prompt_200')
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(200)])
await store.loadMoreHistory()
expect(store.historyAssets.at(-1)?.id).toBe('prompt_200')
})
it('sorts paginated items when an existing asset has no timestamp', async () => {
for (let i = 0; i < 200; i++) {
mockAssetMapperOptions.omitCreatedAtForIds.add(`prompt_${i}`)
}
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('should stop loading when no more items', async () => {
// First batch - less than BATCH_SIZE
const firstBatch = Array.from({ length: 50 }, (_, i) =>
@@ -494,6 +555,29 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(store.historyLoading).toBe(false)
expect(store.historyError).toBe(error)
})
it('should preserve existing history when refresh fails', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(0)])
await store.updateHistory()
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
expect(store.historyError).toBe(error)
})
it('should keep empty history when loadMore fails before any load', async () => {
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.loadMoreHistory()
expect(store.historyAssets).toEqual([])
expect(store.historyError).toBe(error)
})
})
describe('Memory Management', () => {
@@ -924,6 +1008,43 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).toHaveBeenCalledTimes(2)
})
it('ignores a model response after the category is invalidated', async () => {
const store = useAssetsStore()
let resolveFetch!: (assets: AssetItem[]) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((resolve) => {
resolveFetch = resolve
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
resolveFetch([createMockAsset('stale-response')])
await request
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
})
it('ignores a model rejection after the category is invalidated', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
let rejectFetch!: (error: Error) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectFetch = reject
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
rejectFetch(new Error('stale rejection'))
await request
expect(store.getError('CheckpointLoaderSimple')).toBeUndefined()
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('shallowReactive state reactivity', () => {
@@ -966,6 +1087,10 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
it('should return empty array for unknown node types', () => {
const store = useAssetsStore()
expect(store.getAssets('UnknownNodeType')).toEqual([])
expect(store.isModelLoading('UnknownNodeType')).toBe(false)
expect(store.getError('UnknownNodeType')).toBeUndefined()
expect(store.hasMore('UnknownNodeType')).toBe(false)
expect(store.hasAssetKey('UnknownNodeType')).toBe(false)
})
it('should not fetch for unknown node types', async () => {
@@ -975,6 +1100,63 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).not.toHaveBeenCalled()
})
it('should refresh an already loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('first')
])
await store.updateModelsForNodeType(nodeType)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('second')
])
await store.updateModelsForNodeType(nodeType)
expect(store.getAssets(nodeType).map((asset) => asset.id)).toEqual([
'second'
])
})
it('reports hasMore for a loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
expect(store.hasMore(nodeType)).toBe(false)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('only-page')
])
await store.updateModelsForNodeType(nodeType)
expect(store.hasMore(nodeType)).toBe(false)
})
it('should record model loading errors', async () => {
const store = useAssetsStore()
const error = new Error('model fetch failed')
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce(error)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')).toBe(error)
expect(store.isModelLoading('CheckpointLoaderSimple')).toBe(false)
consoleSpy.mockRestore()
})
it('should wrap non-error model loading failures', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce('boom')
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')?.message).toBe('boom')
consoleSpy.mockRestore()
})
})
describe('invalidateCategory', () => {
@@ -1129,7 +1311,140 @@ 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 = {
@@ -1237,6 +1552,31 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
'featured'
])
})
it('calls only the remove endpoint when there are no tags to add', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-remove-only', ['models', 'archived'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('CheckpointLoaderSimple')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
total_tags: ['models']
})
await store.updateAssetTags(asset, ['models'], 'CheckpointLoaderSimple')
expect(vi.mocked(assetService.removeAssetTags)).toHaveBeenCalledWith(
'tags-remove-only',
['archived']
)
expect(vi.mocked(assetService.addAssetTags)).not.toHaveBeenCalled()
expect(store.getAssets('CheckpointLoaderSimple')[0].tags).toEqual([
'models'
])
})
})
describe('updateAssetTags partial-failure compensation', () => {
@@ -1351,6 +1691,36 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.hasCategory('tag:models')).toBe(false)
})
it('keeps unrelated tag caches when compensation fails with a cache key', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-target-fail', ['models', 'loras'])
const otherAsset = createMockAsset('tags-other', ['models'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('LoraLoader')
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([otherAsset])
await store.updateModelsForTag('models')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
removed: ['loras'],
total_tags: ['models']
})
vi.mocked(assetService.addAssetTags)
.mockRejectedValueOnce(new Error('500 add failed'))
.mockRejectedValueOnce(new Error('503 compensation failed'))
await store.updateAssetTags(
asset,
['models', 'checkpoints'],
'LoraLoader'
)
expect(store.hasCategory('loras')).toBe(false)
expect(store.hasCategory('tag:models')).toBe(true)
})
it('does not attempt compensation when only the add was attempted', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-add-only-fail', ['models'])
@@ -1483,9 +1853,78 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
const store = useAssetsStore()
expect(store.getInputName('unknown.png')).toBe('unknown.png')
})
it('ignores input assets without hashes', async () => {
mockIsCloud.value = true
try {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
{
id: 'input-1',
name: 'plain.png',
tags: ['input']
}
])
await store.updateInputs()
expect(store.getInputName('plain.png')).toBe('plain.png')
} finally {
mockIsCloud.value = false
}
})
})
describe('updateInputs cloud routing', () => {
it('reads input files from the internal API when isCloud is false', async () => {
const fetchMock = vi.fn().mockResolvedValue(
fromAny<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 {
@@ -1586,6 +2025,18 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputHasMore).toBe(false)
})
it('does not load more flat outputs when there are no more pages', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'one.png')])
)
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(1)
})
it('threads the minted cursor into after on loadMore and omits offset', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
@@ -1800,4 +2251,26 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
})
it('ignores concurrent load more calls while one is active', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], { hasMore: true })
)
const store = useAssetsStore()
await store.updateFlatOutputs()
let resolvePage!: (page: AssetResponse) => void
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(
new Promise<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)
})
})

View File

@@ -849,18 +849,15 @@ export const useAssetsStore = defineStore('assets', () => {
updateModelsForTag('models')
]
const results = await Promise.allSettled([
...nodeTypeUpdates,
...tagUpdates
])
for (const result of results) {
if (result.status === 'rejected') {
console.error(
`Failed to refresh model cache for provider: ${result.reason}`
)
}
}
await Promise.all(
[...nodeTypeUpdates, ...tagUpdates].map((update) =>
update.catch((reason) => {
console.error(
`Failed to refresh model cache for provider: ${reason}`
)
})
)
)
}
)

View File

@@ -90,6 +90,7 @@ vi.mock('firebase/auth', async (importOriginal) => {
onAuthStateChanged: vi.fn(),
onIdTokenChanged: vi.fn(),
signInWithPopup: vi.fn(),
sendPasswordResetEmail: vi.fn(),
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
@@ -99,7 +100,8 @@ vi.mock('firebase/auth', async (importOriginal) => {
setCustomParameters = vi.fn()
},
getAdditionalUserInfo: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined)
setPersistence: vi.fn().mockResolvedValue(undefined),
updatePassword: vi.fn()
}
})
@@ -127,6 +129,18 @@ vi.mock('@/composables/useFeatureFlags', () => ({
})
}))
const mockWorkspaceAuthStore = vi.hoisted(() => ({
unifiedToken: null as string | null,
clearWorkspaceContext: vi.fn(),
mintAtLogin: vi.fn(),
getWorkspaceAuthHeader: vi.fn(),
getWorkspaceToken: vi.fn()
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
}))
// Mock apiKeyAuthStore
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
vi.mock('@/stores/apiKeyAuthStore', () => ({
@@ -163,6 +177,9 @@ describe('useAuthStore', () => {
mockFeatureFlags.teamWorkspacesEnabled = false
mockFeatureFlags.unifiedCloudAuthEnabled = false
mockWorkspaceAuthStore.unifiedToken = null
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
// Setup dialog service mock
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
@@ -275,6 +292,11 @@ describe('useAuthStore', () => {
store.notifyTokenRefreshed()
expect(store.tokenRefreshTrigger).toBe(1)
})
it('ignores null ID token events', () => {
idTokenCallback?.(null)
expect(store.tokenRefreshTrigger).toBe(0)
})
})
it('should initialize with the current user', () => {
@@ -292,6 +314,24 @@ describe('useAuthStore', () => {
)
})
it('mints workspace auth on cloud login and clears it on logout state', () => {
expect(mockWorkspaceAuthStore.mintAtLogin).toHaveBeenCalledOnce()
authStateCallback(null)
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalledOnce()
})
it('does not mint workspace auth outside cloud', () => {
mockWorkspaceAuthStore.mintAtLogin.mockClear()
mockDistributionTypes.isCloud = false
authStateCallback(mockUser)
expect(mockWorkspaceAuthStore.mintAtLogin).not.toHaveBeenCalled()
mockDistributionTypes.isCloud = true
})
it('should properly clean up error state between operations', async () => {
// First, cause an error
const mockError = new Error('Invalid password')
@@ -349,6 +389,30 @@ describe('useAuthStore', () => {
expect(store.loading).toBe(false)
})
it('tracks login when Firebase returns no email', async () => {
const userWithoutEmail = { ...mockUser, email: null }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
user: userWithoutEmail
} as Partial<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 }
@@ -486,6 +550,19 @@ describe('useAuthStore', () => {
).rejects.toThrow()
expect(mockUser.delete).not.toHaveBeenCalled()
})
it('tracks registration when Firebase returns no email', async () => {
const userWithoutEmail = { ...mockUser, email: null }
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: userWithoutEmail
} as Partial<UserCredential> as UserCredential)
await store.register('new@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ email: undefined })
)
})
})
describe('logout', () => {
@@ -619,6 +696,54 @@ describe('useAuthStore', () => {
const authHeader = await store.getAuthHeader()
expect(authHeader).toBeNull() // Should fallback gracefully
})
it('uses the unified cloud token when enabled', async () => {
mockFeatureFlags.unifiedCloudAuthEnabled = true
mockWorkspaceAuthStore.unifiedToken = 'unified-token'
await expect(store.getAuthHeader()).resolves.toEqual({
Authorization: 'Bearer unified-token'
})
await expect(store.getAuthToken()).resolves.toBe('unified-token')
})
it('returns no unified auth when the unified token is missing', async () => {
mockFeatureFlags.unifiedCloudAuthEnabled = true
mockWorkspaceAuthStore.unifiedToken = null
await expect(store.getAuthHeader()).resolves.toBeNull()
await expect(store.getAuthToken()).resolves.toBeUndefined()
})
it('prefers workspace auth when team workspaces are enabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue({
Authorization: 'Bearer workspace-header'
})
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(
'workspace-token'
)
await expect(store.getAuthHeader()).resolves.toEqual({
Authorization: 'Bearer workspace-header'
})
await expect(store.getAuthToken()).resolves.toBe('workspace-token')
})
it('falls back to Firebase when workspace auth is unavailable', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
await expect(store.getAuthHeader()).resolves.toEqual({
Authorization: 'Bearer mock-id-token'
})
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
})
it('returns the Firebase token by default', async () => {
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
})
})
describe('social authentication', () => {
@@ -804,6 +929,22 @@ describe('useAuthStore', () => {
)
}
)
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track undefined email when Firebase returns no email',
async (method) => {
const userWithoutEmail = { ...mockUser, email: null }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
user: userWithoutEmail
} as Partial<UserCredential> as UserCredential)
await store[method]()
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ email: undefined })
)
}
)
})
})
@@ -975,6 +1116,61 @@ describe('useAuthStore', () => {
await expect(store.accessBillingPortal()).rejects.toThrow()
})
it('throws when no auth method is available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue(null)
await expect(store.accessBillingPortal()).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
})
})
describe('fetchBalance', () => {
it('stores the balance and update time when fetching succeeds', async () => {
await expect(store.fetchBalance()).resolves.toEqual({ balance: 0 })
expect(store.balance).toEqual({ balance: 0 })
expect(store.lastBalanceUpdateTime).toBeInstanceOf(Date)
expect(store.isFetchingBalance).toBe(false)
})
it('throws when no auth method is available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue(null)
await expect(store.fetchBalance()).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
expect(store.isFetchingBalance).toBe(false)
})
it('returns null when the customer balance is missing', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404
})
await expect(store.fetchBalance()).resolves.toBeNull()
expect(store.balance).toBeNull()
expect(store.isFetchingBalance).toBe(false)
})
it('throws API errors when fetching balance fails', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ message: 'Balance unavailable' })
})
await expect(store.fetchBalance()).rejects.toThrow(
'toastMessages.failedToFetchBalance'
)
expect(store.isFetchingBalance).toBe(false)
})
})
describe('getAuthHeaderOrThrow', () => {
@@ -1062,5 +1258,117 @@ describe('useAuthStore', () => {
expect(error).toBeInstanceOf(AuthStoreError)
expect((error as AuthStoreError).status).toBe(422)
})
it('throws when the response has no customer id', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({})
})
await expect(store.createCustomer()).rejects.toThrow(
'toastMessages.failedToCreateCustomer'
)
})
})
describe('password actions', () => {
it('sends password reset emails', async () => {
vi.mocked(firebaseAuth.sendPasswordResetEmail).mockResolvedValue()
await store.sendPasswordReset('test@example.com')
expect(firebaseAuth.sendPasswordResetEmail).toHaveBeenCalledWith(
mockAuth,
'test@example.com'
)
})
it('updates the current user password', async () => {
vi.mocked(firebaseAuth.updatePassword).mockResolvedValue()
await store.updatePassword('new-password')
expect(firebaseAuth.updatePassword).toHaveBeenCalledWith(
mockUser,
'new-password'
)
})
it('throws when updating password without a user', async () => {
authStateCallback(null)
await expect(store.updatePassword('new-password')).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
})
})
describe('initiateCreditPurchase', () => {
it('creates the customer once before adding credits', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.endsWith('/customers')) {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/credit')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ redirect_url: 'https://stripe.test' })
})
}
return Promise.reject(new Error('Unexpected API call'))
})
await store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
await store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
const customerCalls = mockFetch.mock.calls.filter(([url]) =>
String(url).endsWith('/customers')
)
expect(customerCalls).toHaveLength(1)
})
it('throws when credit purchase fails', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.endsWith('/customers')) {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/credit')) {
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ message: 'Checkout unavailable' })
})
}
return Promise.reject(new Error('Unexpected API call'))
})
await expect(
store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
).rejects.toThrow('toastMessages.failedToInitiateCreditPurchase')
})
it('throws when no auth method is available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue(null)
await expect(
store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
})
})
})

View File

@@ -93,6 +93,17 @@ describe('bootstrapStore', () => {
})
})
it('does not reload authenticated stores after bootstrap already ran', async () => {
const store = useBootstrapStore()
await store.startStoreBootstrap()
await store.startStoreBootstrap()
await vi.waitFor(() => {
expect(store.isI18nReady).toBe(true)
})
})
describe('cloud mode', () => {
beforeEach(() => {
mockDistributionTypes.isCloud = true

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -177,9 +178,10 @@ describe('useComfyRegistryStore', () => {
it('should return null when fetching a pack with null ID', async () => {
const store = useComfyRegistryStore()
vi.spyOn(store.getPackById, 'call').mockResolvedValueOnce(null)
const result = await store.getPackById.call(null!)
const result = await store.getPackById.call(
fromAny<Parameters<typeof store.getPackById.call>[0], unknown>(null)
)
expect(result).toBeNull()
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()
@@ -206,6 +208,56 @@ describe('useComfyRegistryStore', () => {
)
})
it('should reuse cached packs by ID', async () => {
const store = useComfyRegistryStore()
await store.getPacksByIds.call(['test-pack-id'])
const result = await store.getPacksByIds.call(['test-pack-id'])
expect(result).toEqual([mockNodePack])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledTimes(1)
})
it('should ignore missing packs by ID', async () => {
mockRegistryService.listAllPacks.mockResolvedValueOnce({
nodes: [fromAny<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()

View File

@@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCommandStore } from '@/stores/commandStore'
const keybindingMock = vi.hoisted(() => ({
value: null as null | { combo: { getKeySequences: () => string[] } }
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
@@ -21,12 +25,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => null
getKeybindingByCommandId: () => keybindingMock.value
})
}))
describe('commandStore', () => {
beforeEach(() => {
keybindingMock.value = null
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -164,6 +169,16 @@ describe('commandStore', () => {
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
})
it('resolves icon as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'icon.fn',
function: vi.fn(),
icon: () => 'pi pi-bolt'
})
expect(store.getCommand('icon.fn')?.icon).toBe('pi pi-bolt')
})
it('uses explicit menubarLabel over label', () => {
const store = useCommandStore()
store.registerCommand({
@@ -184,6 +199,16 @@ describe('commandStore', () => {
})
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
})
it('resolves menubarLabel as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.fn',
function: vi.fn(),
menubarLabel: () => 'Dynamic menu'
})
expect(store.getCommand('mbl.fn')?.menubarLabel).toBe('Dynamic menu')
})
})
describe('formatKeySequence', () => {
@@ -193,5 +218,17 @@ describe('commandStore', () => {
const cmd = store.getCommand('no.kb')!
expect(store.formatKeySequence(cmd)).toBe('')
})
it('formats keybinding sequences', () => {
const store = useCommandStore()
keybindingMock.value = {
combo: { getKeySequences: () => ['Control+A', 'Shift+B'] }
}
store.registerCommand({ id: 'with.kb', function: vi.fn() })
const cmd = store.getCommand('with.kb')!
expect(store.formatKeySequence(cmd)).toBe('Ctrl+A + Shift+B')
})
})
})

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
@@ -141,6 +141,110 @@ describe('dialogStore', () => {
})
describe('basic dialog operations', () => {
it('generates a key when none is provided', () => {
const store = useDialogStore()
const dialog = store.showDialog({ component: MockComponent })
expect(dialog.key).toMatch(/^dialog-/)
expect(store.isDialogOpen(dialog.key)).toBe(true)
})
it('evicts the first stack entry when the stack is full', () => {
const store = useDialogStore()
for (let i = 0; i < 11; i++) {
store.showDialog({
key: `dialog-${i}`,
component: MockComponent,
priority: i
})
}
expect(store.dialogStack).toHaveLength(10)
expect(store.isDialogOpen('dialog-9')).toBe(false)
})
it('stores optional header and footer components and props', () => {
const store = useDialogStore()
const dialog = store.showDialog({
key: 'with-slots',
component: MockComponent,
headerComponent: MockComponent,
footerComponent: MockComponent,
headerProps: { title: 'Header' },
footerProps: { action: 'Save' }
})
expect(dialog.headerComponent).toBeDefined()
expect(dialog.footerComponent).toBeDefined()
expect(dialog.headerProps).toEqual({ title: 'Header' })
expect(dialog.footerProps).toEqual({ action: 'Save' })
})
it('runs dialog lifecycle handlers', () => {
const store = useDialogStore()
const onClose = vi.fn()
const dialog = store.showDialog({
key: 'lifecycle',
component: MockComponent,
dialogComponentProps: { onClose }
})
const props =
dialog.dialogComponentProps as typeof dialog.dialogComponentProps & {
onAfterHide: () => void
onMaximize: () => void
onUnmaximize: () => void
pt: { root: { onMousedown: () => void } }
}
props.onMaximize()
expect(dialog.dialogComponentProps.maximized).toBe(true)
props.onUnmaximize()
expect(dialog.dialogComponentProps.maximized).toBe(false)
props.pt.root.onMousedown()
expect(store.activeKey).toBe('lifecycle')
props.onAfterHide()
expect(onClose).toHaveBeenCalledOnce()
expect(store.isDialogOpen('lifecycle')).toBe(false)
})
it('does nothing when rising or closing a missing dialog', () => {
const store = useDialogStore()
store.riseDialog({ key: 'missing' })
store.closeDialog({ key: 'missing' })
expect(store.dialogStack).toEqual([])
expect(store.activeKey).toBeNull()
})
it('closes the active dialog when no key is provided', () => {
const store = useDialogStore()
store.showDialog({ key: 'active', component: MockComponent })
store.closeDialog()
expect(store.isDialogOpen('active')).toBe(false)
expect(store.activeKey).toBeNull()
})
it('disables escape closing for a non-closable active dialog', () => {
const store = useDialogStore()
const dialog = store.showDialog({
key: 'locked',
component: MockComponent,
dialogComponentProps: { closable: false }
})
expect(dialog.dialogComponentProps.closeOnEscape).toBe(false)
})
it('should show and close dialogs', () => {
const store = useDialogStore()
@@ -208,6 +312,86 @@ describe('dialogStore', () => {
false
)
})
it('updates only content props when dialog component props are omitted', () => {
const store = useDialogStore()
store.showDialog({
key: 'content-only',
component: MockContentPropsComponent,
props: { openingAction: null }
})
expect(
store.updateDialog({
key: 'content-only',
contentProps: { openingAction: 'open' }
})
).toBe(true)
expect(store.dialogStack[0].contentProps.openingAction).toBe('open')
})
it('updates only dialog component props when content props are omitted', () => {
const store = useDialogStore()
store.showDialog({
key: 'dialog-props-only',
component: MockContentPropsComponent,
dialogComponentProps: { dismissableMask: true }
})
expect(
store.updateDialog({
key: 'dialog-props-only',
dialogComponentProps: { dismissableMask: false }
})
).toBe(true)
expect(store.dialogStack[0].dialogComponentProps.dismissableMask).toBe(
false
)
})
it('returns false when updating a missing dialog', () => {
const store = useDialogStore()
expect(
store.updateDialog({
key: 'missing',
contentProps: { openingAction: 'open' }
})
).toBe(false)
})
it('creates and reuses extension dialogs with extension-prefixed keys', () => {
const store = useDialogStore()
const first = store.showExtensionDialog({
key: 'external',
component: MockComponent
})
const second = store.showExtensionDialog({
key: 'extension-external',
component: MockComponent
})
expect(first?.key).toBe('extension-external')
expect(second?.key).toBe(first?.key)
expect(store.dialogStack).toHaveLength(1)
})
it('rejects extension dialogs without keys', () => {
const store = useDialogStore()
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const dialog = store.showExtensionDialog({
key: '',
component: MockComponent
})
expect(dialog).toBeUndefined()
expect(error).toHaveBeenCalledWith('Extension dialog key is required')
error.mockRestore()
})
})
describe('ESC key behavior with multiple dialogs', () => {

View File

@@ -112,6 +112,36 @@ describe('domWidgetStore', () => {
store.activateWidget('non-existent')
}).not.toThrow()
})
it('should ignore deactivating non-existent widgets', () => {
store.deactivateWidget('non-existent')
expect(store.widgetStates.size).toBe(0)
})
it('should replace registered widgets', () => {
const widget = createMockDOMWidget('widget-1')
const replacement = {
...createMockDOMWidget('widget-1'),
value: 'replacement'
}
store.registerWidget(widget)
store.deactivateWidget('widget-1')
store.setWidget(replacement)
const state = store.widgetStates.get('widget-1')
expect(state?.widget.value).toBe('replacement')
expect(state?.active).toBe(true)
})
it('should ignore missing widgets when replacing', () => {
const widget = createMockDOMWidget('widget-1')
store.setWidget(widget)
expect(store.widgetStates.size).toBe(0)
})
})
describe('computed states', () => {

View File

@@ -0,0 +1,26 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const electronAPI = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
vi.mock('@/utils/envUtil', () => ({ electronAPI }))
describe('electronDownloadStore outside desktop', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
electronAPI.mockClear()
})
it('skips the Electron bridge when not running on desktop', async () => {
const store = useElectronDownloadStore()
await store.initialize()
expect(electronAPI).not.toHaveBeenCalled()
expect(store.downloads).toEqual([])
})
})

View File

@@ -0,0 +1,103 @@
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'
)
})
})

View File

@@ -33,18 +33,15 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
}
DownloadManager.onDownloadProgress((data) => {
if (!findByUrl(data.url)) {
downloads.value.push(data)
}
const download = findByUrl(data.url)
if (download) {
download.progress = data.progress
download.status = data.status
download.filename = data.filename
download.savePath = data.savePath
if (!download) {
downloads.value.push(data)
return
}
download.progress = data.progress
download.status = data.status
download.filename = data.filename
download.savePath = data.savePath
})
}

View File

@@ -0,0 +1,188 @@
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: [] } })
})
})

View File

@@ -2,8 +2,10 @@ import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingNodeType } from '@/types/comfy'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeLocatorId } from '@/types/nodeIdentification'
// Mock dependencies
vi.mock('@/i18n', () => ({
@@ -15,6 +17,53 @@ vi.mock('@/platform/distribution/types', () => ({
}))
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
const {
mockApp,
mockCanvasStore,
mockExecutionIdToNodeLocatorId,
mockGetExecutionIdByNode,
mockGetNodeByExecutionId,
mockWorkflowStore
} = vi.hoisted(() => ({
mockApp: {
isGraphReady: true,
rootGraph: {}
},
mockCanvasStore: {
currentGraph: undefined as object | undefined
},
mockExecutionIdToNodeLocatorId: vi.fn(
(_rootGraph: unknown, id: string) => id as NodeLocatorId
),
mockGetExecutionIdByNode: vi.fn(),
mockGetNodeByExecutionId: vi.fn(),
mockWorkflowStore: {
nodeLocatorIdToNodeId: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: (
...args: Parameters<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(() => ({
@@ -39,6 +88,21 @@ 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())
@@ -144,6 +208,31 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('does nothing when the requested slot has no errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'value_bigger_than_max',
message: 'Max exceeded',
details: '',
extra_info: { input_name: 'otherSlot' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
'testSlot'
)
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('preserves complex errors when slot has both simple and complex errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
@@ -388,6 +477,358 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors).not.toBeNull()
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('keeps numeric range errors when no range options prove them valid', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'value_bigger_than_max',
message: '...',
details: '',
extra_info: { input_name: 'testWidget' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
15
)
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('clears simple widget errors when the numeric value has no node error entry', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'999': {
errors: [
{
type: 'value_bigger_than_max',
message: '...',
details: '',
extra_info: { input_name: 'testWidget' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
15,
{ max: 10 }
)
expect(store.lastNodeErrors?.['999'].errors).toHaveLength(1)
})
})
describe('startup clearing', () => {
it('clears execution-start errors and closes the overlay when node errors are empty', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '1' })
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastNodeErrors = {}
store.showErrorOverlay()
store.clearExecutionStartErrors()
expect(store.lastExecutionError).toBeNull()
expect(store.lastPromptError).toBeNull()
expect(store.isErrorOverlayOpen).toBe(false)
})
it('keeps the overlay open when node errors remain after execution start', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '1' })
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
store.showErrorOverlay()
store.clearExecutionStartErrors()
expect(store.isErrorOverlayOpen).toBe(true)
})
})
})
describe('executionErrorStore derived graph state', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('derives execution error node ids through locator mapping', () => {
const store = useExecutionErrorStore()
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<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)
})
})
@@ -457,6 +898,23 @@ describe('surfaceMissingModels — silent option', () => {
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay when the setting is disabled', () => {
const store = useExecutionErrorStore()
mockShowErrorsTab.value = false
store.surfaceMissingModels([
fromAny({
name: 'model.safetensors',
nodeId: toNodeId('1'),
nodeType: 'Loader',
widgetName: 'ckpt',
isMissing: true,
isAssetSupported: false
})
])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('surfaceMissingMedia — silent option', () => {
@@ -525,6 +983,23 @@ describe('surfaceMissingMedia — silent option', () => {
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay when the setting is disabled', () => {
const store = useExecutionErrorStore()
mockShowErrorsTab.value = false
store.surfaceMissingMedia([
fromAny({
name: 'photo.png',
nodeId: toNodeId('1'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
isMissing: true
})
])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('clearAllErrors', () => {

View File

@@ -0,0 +1,120 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers, openSet } = vi.hoisted(() => ({
handlers: {} as Record<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)
})
})

View File

@@ -0,0 +1,119 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers } = vi.hoisted(() => ({
handlers: {} as Record<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)
})
})

View File

@@ -0,0 +1,131 @@
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'])
})
})

View File

@@ -0,0 +1,173 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
import type { classifyCloudValidationError } from '@/utils/executionErrorUtil'
type CloudValidationResult = ReturnType<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: ''
})
})
})

View File

@@ -423,6 +423,124 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
'running'
)
})
it('keeps an existing error state when later progress maps to the same locator', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'error',
value: 0,
max: 100,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123:456',
state: 'running',
value: 50,
max: 100,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
.state
).toBe('error')
})
it('ignores finished progress when current state is already running', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'running',
value: 5,
max: 10,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'finished',
value: 10,
max: 10,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 5 })
})
it('keeps later running progress from moving a locator backwards', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'running',
value: 6,
max: 10,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'running',
value: 8,
max: 10,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 6, max: 10 })
})
it('merges zero-max running progress without dividing by zero', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'pending',
value: 0,
max: 0,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'running',
value: 0,
max: 0,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 0, max: 0 })
})
it('skips nested progress when the execution id cannot be resolved', () => {
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(null)
store.nodeProgressStates = {
node1: {
display_node_id: '404:1',
state: 'running',
value: 5,
max: 10,
prompt_id: 'test',
node_id: 'node1'
}
}
expect(store.nodeLocationProgressStates).toHaveProperty('404')
expect(store.nodeLocationProgressStates).not.toHaveProperty('404:1')
})
})
describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
@@ -551,6 +669,31 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
expect(store.initializingJobIds).toEqual(new Set())
})
it('clears initialization ids directly', () => {
store.initializingJobIds = new Set(['job-1'])
store.clearInitializationByJobId(null)
store.clearInitializationByJobId('missing')
store.clearInitializationByJobId('job-1')
expect(store.initializingJobIds).toEqual(new Set())
})
it('checks initializing jobs by stringified id', () => {
store.initializingJobIds = new Set(['7'])
expect(store.isJobInitializing(undefined)).toBe(false)
expect(store.isJobInitializing(7)).toBe(true)
})
it('does not rewrite initializing state when no requested ids are tracked', () => {
store.initializingJobIds = new Set(['job-1'])
store.clearInitializationByJobIds(['missing'])
expect(store.initializingJobIds).toEqual(new Set(['job-1']))
})
})
describe('useExecutionStore - workflowStatus', () => {
@@ -675,6 +818,16 @@ describe('useExecutionStore - workflowStatus', () => {
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('leaves workflowStatus unchanged when open workflows are unchanged', async () => {
callStoreJob('job-a', workflowA)
fireExecutionSuccess('job-a')
mockOpenWorkflows.value = [workflowA, workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('sets failed on execution_error', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
@@ -691,6 +844,14 @@ describe('useExecutionStore - workflowStatus', () => {
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('handles interrupt for a queued workflow with no active job', () => {
callStoreJob('job-1', workflowA)
fireExecutionInterrupted('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('evicts the oldest pending status once the buffer cap is exceeded', () => {
// Each start with no matching storeJob buffers a 'running' status. One
// past the cap evicts the oldest so the buffer can't grow unbounded.
@@ -900,6 +1061,35 @@ describe('useExecutionStore - progress_text startup guard', () => {
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
})
it('should ignore progress_text for another active prompt', async () => {
const mockNode = createMockLGraphNode({ id: 1 })
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
useCanvasStore().canvas = {
graph: { getNodeById: vi.fn(() => mockNode) }
} as unknown as LGraphCanvas
store.activeJobId = 'job-1'
fireProgressText({
nodeId: toNodeId('1'),
text: 'warming up',
prompt_id: 'job-2'
})
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore progress_text without text or node id', () => {
fireProgressText({ nodeId: toNodeId('1'), text: '' })
fireProgressText({
nodeId: '' as ReturnType<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')
@@ -915,6 +1105,19 @@ describe('useExecutionStore - progress_text startup guard', () => {
expect(mockExecutionIdToCurrentId).toHaveBeenCalledWith('1:2')
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore progress_text when the current node id cannot be parsed', async () => {
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
useCanvasStore().canvas = {
graph: { getNodeById: vi.fn() }
} as unknown as LGraphCanvas
mockExecutionIdToCurrentId.mockReturnValue({})
fireProgressText({ nodeId: toNodeId('1:2'), text: 'warming up' })
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
})
describe('useExecutionErrorStore - Node Error Lookups', () => {
@@ -1375,6 +1578,21 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.initializingJobIds.has('job-1')).toBe(false)
expect(store.initializingJobIds.has('job-2')).toBe(true)
})
it('captures a queued workflow path when the start event wins the race', () => {
store.queuedJobs = {
'job-1': {
nodes: {},
workflow: createQueuedWorkflow('/workflows/race.json')
}
}
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
'/workflows/race.json'
)
})
})
describe('execution_cached', () => {
@@ -1562,9 +1780,35 @@ describe('useExecutionStore - WebSocket event handlers', () => {
is_app_mode: true
})
})
it('uses current mode when shared queued job has no queued mode snapshot', () => {
mockAppModeState.mode.value = 'app'
mockAppModeState.isAppMode.value = true
store.queuedJobs = {
'job-1': {
nodes: {},
shareId: 'share-1'
}
}
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
})
})
describe('executing', () => {
it('is a no-op when there is no active job', () => {
fire('executing', null)
expect(store.activeJobId).toBeNull()
})
it('clears _executingNodeProgress and activeJobId when detail is null', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
store._executingNodeProgress = {
@@ -1590,7 +1834,31 @@ 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' }
@@ -1610,6 +1878,18 @@ 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', () => {
@@ -1631,6 +1911,39 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
})
it('uses the message directly for service-level errors without a type', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: null,
exception_message: 'Job failed before node execution',
traceback: []
})
expect(errorStore.lastPromptError).toMatchObject({
type: 'error',
message: 'Job failed before node execution',
details: ''
})
})
it('uses an empty prompt message for service-level errors without backend copy', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: null,
traceback: []
})
expect(errorStore.lastPromptError).toMatchObject({
type: 'error',
message: '',
details: ''
})
})
it('routes a runtime error (with node_id) to lastExecutionError', () => {
const errorStore = useExecutionErrorStore()
@@ -1744,6 +2057,12 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.initializingJobIds.has('job-9')).toBe(false)
})
it('ignores notifications without text', () => {
fire('notification', { id: 'job-9' })
expect(store.initializingJobIds.has('job-9')).toBe(false)
})
})
describe('unbindExecutionEvents', () => {
@@ -1813,6 +2132,45 @@ 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')
@@ -1829,4 +2187,58 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
})
it('evicts the oldest workflow paths when the session map exceeds capacity', () => {
for (let i = 0; i < 4001; i++) {
store.ensureSessionWorkflowPath(`job-${i}`, `/workflow-${i}.json`)
}
expect(store.jobIdToSessionWorkflowPath.size).toBe(4000)
expect(store.jobIdToSessionWorkflowPath.has('job-0')).toBe(false)
expect(store.jobIdToSessionWorkflowPath.get('job-4000')).toBe(
'/workflow-4000.json'
)
})
it('reports whether the active workflow is running', () => {
mockActiveWorkflow.value = { path: '/workflows/foo.json' }
store.activeJobId = 'job-1'
store.ensureSessionWorkflowPath('job-1', '/workflows/foo.json')
expect(store.isActiveWorkflowRunning).toBe(true)
store.ensureSessionWorkflowPath('job-1', '/workflows/bar.json')
expect(store.isActiveWorkflowRunning).toBe(false)
mockActiveWorkflow.value = {}
expect(store.isActiveWorkflowRunning).toBe(false)
})
it('counts running jobs from progress state', () => {
store.nodeProgressStatesByJob = {
'job-1': {
a: {
value: 1,
max: 10,
state: 'running',
node_id: 'a',
display_node_id: 'a',
prompt_id: 'job-1'
}
},
'job-2': {
b: {
value: 10,
max: 10,
state: 'finished',
node_id: 'b',
display_node_id: 'b',
prompt_id: 'job-2'
}
}
}
expect(store.runningJobIds).toEqual(['job-1'])
expect(store.runningWorkflowCount).toBe(1)
})
})

View File

@@ -153,9 +153,9 @@ export const useExecutionStore = defineStore('execution', () => {
pendingWorkflowStatusByJobId.delete(jobId)
pendingWorkflowStatusByJobId.set(jobId, status)
while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) {
const oldest = pendingWorkflowStatusByJobId.keys().next().value
if (oldest === undefined) break
pendingWorkflowStatusByJobId.delete(oldest)
pendingWorkflowStatusByJobId.delete(
pendingWorkflowStatusByJobId.keys().next().value as string
)
}
}
@@ -314,8 +314,8 @@ export const useExecutionStore = defineStore('execution', () => {
: null
)
const activeJob = computed<QueuedJob | undefined>(
() => queuedJobs.value[activeJobId.value ?? '']
const activeJob = computed<QueuedJob | undefined>(() =>
activeJobId.value ? queuedJobs.value[activeJobId.value] : undefined
)
const totalNodesToExecute = computed<number>(() => {
@@ -440,9 +440,7 @@ export const useExecutionStore = defineStore('execution', () => {
// Update the executing nodes list
if (e.detail == null) {
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
}
delete queuedJobs.value[activeJobId.value as JobId]
activeJobId.value = null
}
}
@@ -593,7 +591,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleCloudValidationError(
detail: ExecutionErrorWsMessage
): boolean {
const result = classifyCloudValidationError(detail.exception_message)
const result = classifyCloudValidationError(detail.exception_message ?? '')
if (!result) return false
clearInitializationByJobId(detail.prompt_id)
@@ -669,17 +667,14 @@ export const useExecutionStore = defineStore('execution', () => {
/**
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: JobId | null) {
function resetExecutionState(jobId: JobId) {
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null
if (jobId) {
const map = { ...nodeProgressStatesByJob.value }
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
jobIdToWorkflow.delete(jobId)
}
const map = { ...nodeProgressStatesByJob.value }
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
jobIdToWorkflow.delete(jobId)
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
}
@@ -771,9 +766,7 @@ export const useExecutionStore = defineStore('execution', () => {
const next = new Map(jobIdToSessionWorkflowPath.value)
next.set(jobId, path)
while (next.size > MAX_SESSION_PATH_ENTRIES) {
const oldest = next.keys().next().value
if (oldest !== undefined) next.delete(oldest)
else break
next.delete(next.keys().next().value as JobId)
}
jobIdToSessionWorkflowPath.value = next
}

View File

@@ -0,0 +1,153 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers, openSet } = vi.hoisted(() => ({
handlers: {} as Record<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()
})
})

View File

@@ -1,6 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
@@ -71,6 +71,14 @@ describe('jobPreviewStore', () => {
expect(store.previewsByPromptId).toEqual({ p2: 'blob:b' })
})
it('ignores clearPreview without a prompt id', () => {
const store = useJobPreviewStore()
store.clearPreview(undefined)
expect(store.nodePreviewsByPromptId).toEqual({})
})
it('clears all previews', () => {
const store = useJobPreviewStore()
store.setPreviewUrl('p1', 'blob:a', 'node-1')
@@ -91,6 +99,24 @@ describe('jobPreviewStore', () => {
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
})
it('ignores missing prompt ids', () => {
const store = useJobPreviewStore()
store.setPreviewUrl(undefined, 'blob:a', 'node-1')
expect(store.nodePreviewsByPromptId).toEqual({})
})
it('releases the old url when replacing a preview', () => {
const store = useJobPreviewStore()
store.setPreviewUrl('p1', 'blob:a', 'node-1')
store.setPreviewUrl('p1', 'blob:b', 'node-1')
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
expect(store.nodePreviewsByPromptId['p1']?.url).toBe('blob:b')
})
it('ignores setPreviewUrl when previews are disabled', () => {
previewMethodRef.value = 'none'
const store = useJobPreviewStore()
@@ -99,4 +125,15 @@ describe('jobPreviewStore', () => {
expect(store.nodePreviewsByPromptId).toEqual({})
})
it('clears previews when previews are disabled after storage', async () => {
const store = useJobPreviewStore()
store.setPreviewUrl('p1', 'blob:a', 'node-1')
previewMethodRef.value = 'none'
await nextTick()
expect(store.nodePreviewsByPromptId).toEqual({})
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
})
})

View File

@@ -0,0 +1,149 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
const canvasStoreMock = vi.hoisted(() => ({ linearMode: false }))
vi.mock('@/constants/coreMenuCommands', () => ({
CORE_MENU_COMMANDS: [[['Core'], ['core.command']]]
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
(fn: () => Promise<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')
})
})

View File

@@ -137,6 +137,88 @@ describe('useModelStore', () => {
expect(model.resolution).toBe('')
})
it('keeps the default model metadata when the server returns null', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce(null)
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
expect(model.has_loaded_metadata).toBe(false)
})
it('loads model metadata once', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
await model.load()
expect(api.viewMetadata).toHaveBeenCalledTimes(1)
})
it('keeps the default title when the first metadata key is empty', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'modelspec.title': '',
display_name: 'Fallback title'
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
})
it('returns null for unknown loaded model folders', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await expect(store.getLoadedModelFolder('missing')).resolves.toBeNull()
})
it('should read metadata from suffixed keys and ignore null values', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'custom.modelspec.title': 'Namespaced title',
'custom.modelspec.author': null,
'custom.modelspec.tags': null
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('Namespaced title')
expect(model.author).toBe('')
expect(model.tags).toEqual([''])
})
it('should keep extensions for non-safetensors files', async () => {
enableMocks()
vi.mocked(api.getModels).mockResolvedValueOnce([
{ name: 'notes.txt', pathIndex: 0 }
])
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore!.models['0/notes.txt'].title).toBe('notes.txt')
})
it('should cache model information', async () => {
enableMocks()
store = useModelStore()
@@ -209,6 +291,23 @@ describe('useModelStore', () => {
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(api.getModels).not.toHaveBeenCalled()
})
it('does not reload previously loaded folders that disappear', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
vi.mocked(api.getModelFolders).mockResolvedValueOnce([
{ name: 'vae', folders: ['/path/to/vae'] }
])
await store.refresh()
expect(store.modelFolders.map((folder) => folder.directory)).toEqual([
'vae'
])
expect(api.getModels).toHaveBeenCalledTimes(1)
})
})
describe('API switching functionality', () => {

View File

@@ -69,7 +69,9 @@ export class ComfyModelDef {
this.path_index = pathIndex
this.file_name = name
this.normalized_file_name = name.replaceAll('\\', '/')
this.simplified_file_name = this.normalized_file_name.split('/').pop() ?? ''
this.simplified_file_name = this.normalized_file_name.slice(
this.normalized_file_name.lastIndexOf('/') + 1
)
if (this.simplified_file_name.endsWith('.safetensors')) {
this.simplified_file_name = this.simplified_file_name.slice(
0,

View File

@@ -138,6 +138,22 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('ckpt_name')
})
it('omits providers whose node definition is unavailable from reverse lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(modelToNodeStore.getRegisteredNodeTypes()).not.toHaveProperty(
'undefined'
)
})
it('should return undefined for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
@@ -577,6 +593,22 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('skips providers without node definitions during category lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(
modelToNodeStore.getCategoryForNodeType('MissingNode')
).toBeUndefined()
})
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()

View File

@@ -0,0 +1,236 @@
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' }
})
})
})

View File

@@ -50,9 +50,9 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
.map((bookmark: string) => {
if (bookmark.endsWith('/')) return createDummyFolderNodeDef(bookmark)
const parts = bookmark.split('/')
const name = parts.pop() ?? ''
const category = parts.join('/')
const slashIndex = bookmark.lastIndexOf('/')
const name = bookmark.slice(slashIndex + 1)
const category = bookmark.slice(0, Math.max(0, slashIndex))
const srcNodeDef = nodeDefStore.allNodeDefsByName[name]
if (!srcNodeDef) {
return null

View File

@@ -1,16 +1,24 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import axios from 'axios'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
createDummyFolderNodeDef,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
@@ -21,6 +29,10 @@ describe('useNodeDefStore', () => {
store = useNodeDefStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef => ({
@@ -39,7 +51,112 @@ describe('useNodeDefStore', () => {
...overrides
})
describe('ComfyNodeDefImpl', () => {
it('migrates defaultInput options and applies constructor fallbacks', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const nodeDef = createMockNodeDef({
category: '_for_testing/coverage',
deprecated: undefined,
dev_only: undefined,
experimental: undefined,
help: undefined,
input: {
required: { prompt: ['STRING', { defaultInput: true }] },
optional: { seed_override: ['INT', { defaultInput: true }] }
}
})
const impl = new ComfyNodeDefImpl(nodeDef)
expect(warn).toHaveBeenCalledTimes(2)
expect(impl.help).toBe('')
expect(impl.experimental).toBe(true)
expect(impl.dev_only).toBe(false)
expect(impl.inputs.seed_override.forceInput).toBe(true)
})
it('derives empty-category node paths and lifecycle badges', () => {
const deprecated = new ComfyNodeDefImpl(
createMockNodeDef({ category: '', deprecated: undefined })
)
const beta = new ComfyNodeDefImpl(
createMockNodeDef({ experimental: true })
)
const dev = new ComfyNodeDefImpl(createMockNodeDef({ dev_only: true }))
const normal = new ComfyNodeDefImpl(createMockNodeDef())
expect(deprecated.nodePath).toBe('TestNode')
expect(deprecated.isDummyFolder).toBe(false)
expect(deprecated.nodeLifeCycleBadgeText).toBe('[DEPR]')
expect(beta.nodeLifeCycleBadgeText).toBe('[BETA]')
expect(dev.nodeLifeCycleBadgeText).toBe('[DEV]')
expect(normal.nodeLifeCycleBadgeText).toBe('')
})
it('defaults missing legacy input and output fields', () => {
const nodeDef = new ComfyNodeDefImpl(
fromAny<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',
@@ -287,6 +404,26 @@ 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' })
@@ -336,6 +473,39 @@ describe('useNodeDefStore', () => {
expect(store.allNodeDefsByName).toHaveProperty('Normal')
expect(store.allNodeDefsByName).toHaveProperty('Deprecated')
})
it('derives unique input and output data types', () => {
store.updateNodeDefs([
createMockNodeDef({
input: {
required: { image: ['IMAGE', {}] },
optional: { mask: ['MASK', {}] }
},
output: ['IMAGE', 'LATENT'],
output_is_list: [false, false],
output_name: ['image', 'latent']
})
])
expect([...store.nodeDataTypes].sort()).toEqual([
'IMAGE',
'LATENT',
'MASK'
])
})
it('looks up node definitions from graph nodes and returns null for misses', () => {
store.updateNodeDefs([createMockNodeDef({ name: 'KnownNode' })])
expect(
store.fromLGraphNode(new LGraphNode('KnownNode', 'KnownNode'))?.name
).toBe('KnownNode')
expect(store.fromLGraphNode(new LGraphNode('', ''))).toBeNull()
expect(
store.getInputSpecForWidget(new LGraphNode('Missing', 'Missing'), 'x')
).toBeUndefined()
expect(store.nodeSearchService).toBeDefined()
})
})
describe('subgraph widget input specs', () => {
@@ -389,6 +559,94 @@ describe('useNodeDefStore', () => {
expect(spec?.type).toBe('STRING')
expect(spec?.default).toBeUndefined()
})
it('returns undefined for missing promoted subgraph inputs', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'missing')).toBeUndefined()
})
it('returns undefined when a subgraph input is not promoted', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
host.addInput('raw', 'STRING')
expect(store.getInputSpecForWidget(host, 'raw')).toBeUndefined()
})
it('returns undefined when a promoted source no longer resolves', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
host.subgraph.nodes[0].widgets = []
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
it('returns undefined when concrete promoted widget resolution fails', async () => {
const resolver =
await import('@/core/graph/subgraph/resolveConcretePromotedWidget')
vi.spyOn(resolver, 'resolveConcretePromotedWidget').mockReturnValue(
fromAny({ status: 'failure', failure: 'missing-widget' })
)
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
})
describe('node frequency store', () => {
it('loads frequencies once and exposes top matching node definitions', async () => {
const get = vi.spyOn(axios, 'get').mockResolvedValue({
data: { RankedNode: 10, MissingNode: 3 }
})
store.updateNodeDefs([createMockNodeDef({ name: 'RankedNode' })])
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
await frequencyStore.loadNodeFrequencies()
expect(get).toHaveBeenCalledTimes(1)
expect(frequencyStore.isLoaded).toBe(true)
expect(frequencyStore.getNodeFrequencyByName('RankedNode')).toBe(10)
expect(
frequencyStore.getNodeFrequency(
new ComfyNodeDefImpl(createMockNodeDef({ name: 'RankedNode' }))
)
).toBe(10)
expect(frequencyStore.getNodeFrequencyByName('Unknown')).toBe(0)
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
['RankedNode']
)
})
it('leaves frequency state unloaded when loading fails', async () => {
const error = new Error('boom')
vi.spyOn(axios, 'get').mockRejectedValue(error)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
expect(frequencyStore.isLoaded).toBe(false)
expect(errorSpy).toHaveBeenCalledWith(
'Error loading node frequencies:',
error
)
})
})
describe('performance', () => {

View File

@@ -105,8 +105,12 @@ export class ComfyNodeDefImpl
* @internal
* Migrate default input options to forceInput.
*/
private static _migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 {
const def = _.cloneDeep(nodeDef)
private static _migrateDefaultInput(
nodeDef: ComfyNodeDefV1
): ComfyNodeDefV1 & { input: ComfyInputSpecV1 } {
const def = _.cloneDeep(nodeDef) as ComfyNodeDefV1 & {
input: ComfyInputSpecV1
}
def.input ??= {}
// For required inputs, now we have the input socket always present. Specifying
// it now has no effect.
@@ -156,7 +160,7 @@ export class ComfyNodeDefImpl
this.dev_only = obj.dev_only ?? false
this.output_node = obj.output_node
this.api_node = !!obj.api_node
this.input = obj.input ?? {}
this.input = obj.input
this.output = obj.output ?? []
this.output_is_list = obj.output_is_list
this.output_name = obj.output_name

View File

@@ -3,15 +3,41 @@ import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import * as litegraphUtil from '@/utils/litegraphUtil'
const {
mockApiURL,
mockExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeToNodeLocatorId,
mockReleaseSharedObjectUrl,
mockRetainSharedObjectUrl
} = vi.hoisted(() => ({
mockApiURL: vi.fn((path: string) => `api${path}`),
mockExecutionIdToNodeLocatorId: vi.fn(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
),
mockNodeIdToNodeLocatorId: vi.fn(
(id: string | number) => String(id) as NodeLocatorId
),
mockNodeToNodeLocatorId: vi.fn(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
),
mockReleaseSharedObjectUrl: vi.fn(),
mockRetainSharedObjectUrl: vi.fn()
}))
const mockResolveNode = vi.fn()
vi.mock('@/utils/litegraphUtil', () => ({
@@ -20,11 +46,25 @@ vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: (...args: unknown[]) => mockResolveNode(...args)
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (...args: Parameters<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)
},
@@ -49,13 +89,31 @@ const createMockOutputs = (
): ExecutedWsMessage['output'] => ({ images })
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
executionIdToNodeLocatorId: (
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
) => mockExecutionIdToNodeLocatorId(...args)
}))
beforeEach(() => {
mockExecutionIdToNodeLocatorId.mockImplementation(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
)
mockNodeIdToNodeLocatorId.mockImplementation(
(id: string | number) => String(id) as NodeLocatorId
)
mockNodeToNodeLocatorId.mockImplementation(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
)
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
nodeIdToNodeLocatorId: (
...args: Parameters<typeof mockNodeIdToNodeLocatorId>
) => mockNodeIdToNodeLocatorId(...args),
nodeToNodeLocatorId: (
...args: Parameters<typeof mockNodeToNodeLocatorId>
) => mockNodeToNodeLocatorId(...args)
}))
}))
@@ -780,6 +838,19 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']?.images?.[0]?.type).toBe('input')
})
it('ignores widget outputs when no locator can be resolved', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
mockNodeToNodeLocatorId.mockReturnValueOnce(
fromAny<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 })
@@ -789,6 +860,470 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
it('stores direct result items without wrapping them as image outputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, { filename: 'direct.png', type: 'temp' })
expect(store.nodeOutputs['5']).toEqual({
filename: 'direct.png',
type: 'temp'
})
})
it('marks animated webp and png filenames when requested', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, ['clip.webp', 'still.jpg', 'mask.png'], {
folder: 'output',
isAnimated: true
})
expect(store.nodeOutputs['5']?.animated).toEqual([true, false, true])
expect(store.nodeOutputs['5']?.images?.map((image) => image.type)).toEqual([
'output',
'output',
'output'
])
})
})
describe('nodeOutputStore image URLs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('returns stored preview URLs before output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)), [
'blob:preview'
])
expect(store.getNodeImageUrls(node)).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('builds view URLs from output images', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
app.nodeOutputs['5'] = createMockOutputs(
fromAny([{ filename: 'a.png', subfolder: 'x', type: 'temp' }, null])
)
expect(store.getNodeImageUrls(node)).toEqual([
'api/view?filename=a.png&subfolder=x&type=temp&format=test_webp&rand=1'
])
})
it('returns undefined when a node has neither previews nor outputs', () => {
const store = useNodeOutputStore()
expect(store.getNodeImageUrls(createMockNode({ id: 5 }))).toBeUndefined()
})
it('returns execution previews before execution output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'blob:preview'
])
expect(store.latestPreview).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('falls back to execution output URLs when no preview exists', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'api/view?filename=result.png&type=temp&format=test_webp&rand=1'
])
})
})
describe('nodeOutputStore locator misses', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('keeps execution operations inert when no locator can be resolved', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<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', () => {
@@ -894,4 +1429,20 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})
it('copies output images onto the legacy node', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockResolveNode.mockReturnValue(mockNode)
store.setNodeOutputsByExecutionId(
createNodeExecutionId([toNodeId(1)]),
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
store.syncLegacyNodeImgs(toNodeId(1), mockImg)
expect(mockNode.images).toEqual([{ filename: 'result.png', type: 'temp' }])
})
})

View File

@@ -95,6 +95,22 @@ describe(usePreviewExposureStore, () => {
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
})
it('clears only the requested host when other hosts remain', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
store.addExposure(rootGraphA, hostB, {
sourceNodeId: '43',
sourcePreviewName: 'preview'
})
store.setExposures(rootGraphA, hostA, [])
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
})
})
describe('removeExposure', () => {
@@ -122,6 +138,12 @@ describe(usePreviewExposureStore, () => {
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
})
it('is a no-op for an unknown host', () => {
store.removeExposure(rootGraphA, 'missing-host', 'preview')
expect(store.getExposures(rootGraphA, 'missing-host')).toEqual([])
})
})
describe('getExposuresAsPromotionShape', () => {

View File

@@ -0,0 +1,132 @@
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)
})
})

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -10,6 +10,8 @@ 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', () => ({
@@ -44,7 +46,9 @@ const mockJobDetail = {
}
},
outputs: {
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
'1': {
images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }]
}
}
}
@@ -137,4 +141,98 @@ 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'])
})
})

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,7 +7,14 @@ import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import {
isInstantMode,
isInstantRunningMode,
ResultItemImpl,
TaskItemImpl,
useQueuePendingTaskCountStore,
useQueueStore
} from '@/stores/queueStore'
// Fixture factory for JobListItem
function createJob(
@@ -67,6 +75,86 @@ vi.mock('@/scripts/api', () => ({
}))
describe('TaskItemImpl', () => {
it('should default missing result URL fields', () => {
const output = new ResultItemImpl(
fromAny<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, {
@@ -259,6 +347,41 @@ describe('TaskItemImpl', () => {
expect(taskItem.executionError).toEqual(errorDetail)
})
})
it('should expose queue API task type for running tasks', () => {
const task = new TaskItemImpl(createRunningJob(1, 'run-1'))
expect(task.apiTaskType).toBe('queue')
})
it('should return empty flat outputs when outputs are missing', () => {
const task = new TaskItemImpl(
createHistoryJob(0, 'job-id'),
fromAny<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', () => {
@@ -314,6 +437,23 @@ 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')
@@ -1115,3 +1255,43 @@ 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)
})
})

View File

@@ -273,9 +273,6 @@ export class TaskItemImpl {
}
calculateFlatOutputs(): ReadonlyArray<ResultItemImpl> {
if (!this.outputs) {
return []
}
return parseTaskOutput(this.outputs)
}
@@ -435,9 +432,6 @@ export class TaskItemImpl {
// Use full outputs from job detail, or fall back to existing outputs
const outputsToLoad = jobDetail?.outputs ?? this.outputs
if (!outputsToLoad) {
return
}
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(outputsToLoad)

View File

@@ -0,0 +1,206 @@
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'])
})
})

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
@@ -154,6 +154,22 @@ describe(parseNodeOutput, () => {
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
it('excludes non-object and invalid-type items', () => {
const output = fromAny<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, () => {

View File

@@ -3,6 +3,7 @@ import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import type * as VueRouter from 'vue-router'
@@ -102,12 +103,24 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
function makeSubgraph(id: string): Subgraph {
return fromPartial<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()
@@ -118,6 +131,7 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(app.canvas.setGraph).mockReset()
app.rootGraph.id = ids.root
app.rootGraph.subgraphs.clear()
app.canvas.subgraph = undefined
@@ -230,6 +244,42 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
warnSpy.mockRestore()
})
it('does not warn when recovery redirect hits a duplicated navigation', async () => {
routerMocks.replace.mockRejectedValueOnce(
await makeDuplicatedNavigationFailure()
)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
expect(warnSpy).not.toHaveBeenCalledWith(
'[subgraphNavigation] router.replace rejected during recovery',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('recovers to root when canvas is unavailable during redirect cleanup', async () => {
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
const canvas = appWithOptionalCanvas.canvas
appWithOptionalCanvas.canvas = undefined
useSubgraphNavigationStore()
routeHashRef.value = '#not-a-valid-uuid'
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
appWithOptionalCanvas.canvas = canvas
})
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowStoreState.openWorkflows = [
@@ -304,4 +354,196 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
warnSpy.mockRestore()
})
it('updateHash does nothing on initial load with an empty hash', async () => {
const store = useSubgraphNavigationStore()
await store.updateHash()
expect(routerMocks.replace).not.toHaveBeenCalled()
expect(routerMocks.push).not.toHaveBeenCalled()
})
it('updateHash follows a non-empty initial subgraph hash', async () => {
const subgraph = makeSubgraph(ids.validSubgraph)
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
vi.mocked(app.canvas.setGraph).mockImplementation((graph) => {
app.canvas.graph = graph
})
routeHashRef.value = `#${ids.validSubgraph}`
const store = useSubgraphNavigationStore()
await store.updateHash()
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
})
it('updateHash does not treat the initial root hash as a subgraph', async () => {
routeHashRef.value = `#${ids.root}`
app.canvas.graph = app.rootGraph
const store = useSubgraphNavigationStore()
await store.updateHash()
expect(workflowStoreState.activeSubgraph).toBeUndefined()
})
it('updateHash replaces an empty hash and pushes the active graph id', async () => {
const store = useSubgraphNavigationStore()
await store.updateHash()
app.canvas.graph = fromPartial<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)
})
})

View File

@@ -126,7 +126,6 @@ export const useSubgraphNavigationStore = defineStore(
/** Apply a viewport state to the canvas. */
function applyViewport(viewport: DragAndScaleState): void {
const canvas = app.canvas
if (!canvas) return
canvas.ds.scale = viewport.scale
canvas.ds.offset[0] = viewport.offset[0]
canvas.ds.offset[1] = viewport.offset[1]
@@ -170,7 +169,8 @@ export const useSubgraphNavigationStore = defineStore(
if (!isWorkflowSwitching) {
if (prevSubgraph) {
saveViewport(prevSubgraph.id)
} else if (!prevSubgraph && subgraph) {
}
if (!prevSubgraph && subgraph) {
saveViewport(getCurrentRootGraphId())
}
}

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -136,6 +137,20 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('saveViewport', () => {
it('does not save when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
store.saveViewport('root')
expect(store.viewportCache.has(':root')).toBe(false)
appWithOptionalCanvas.canvas = canvas
})
it('saves viewport state for root graph', () => {
const store = useSubgraphNavigationStore()
mockCanvas.ds.state.scale = 2
@@ -164,6 +179,36 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('restoreViewport', () => {
it('does nothing when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(0)
appWithOptionalCanvas.canvas = canvas
})
it('does not apply cached viewport when canvas disappears', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
appWithOptionalCanvas.canvas = undefined
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
appWithOptionalCanvas.canvas = canvas
})
it('restores cached viewport', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
@@ -266,7 +311,10 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockFitView).toHaveBeenCalledOnce()
// User navigated away before the inner RAF fired
mockCanvas.subgraph = { id: 'different-graph' } as never
mockCanvas.subgraph = fromPartial<Subgraph>({
id: 'different-graph',
isRootGraph: false
})
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
@@ -283,7 +331,10 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(rafCallbacks).toHaveLength(1)
// Simulate graph switching away before rAF fires
mockCanvas.subgraph = { id: 'different-graph' } as never
mockCanvas.subgraph = fromPartial<Subgraph>({
id: 'different-graph',
isRootGraph: false
})
rafCallbacks[0](performance.now())
@@ -341,6 +392,23 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('does not save the outgoing viewport while a workflow switch is blocked', async () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const subgraph = fromPartial<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()

View File

@@ -10,9 +10,13 @@ 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'
@@ -36,6 +40,7 @@ vi.mock('@/scripts/api', () => ({
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
getGlobalSubgraphs: vi.fn(),
deleteUserData: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
@@ -98,6 +103,12 @@ 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 () => {
@@ -134,6 +145,86 @@ describe('useSubgraphStore', () => {
await store.publishSubgraph()
expect(api.storeUserData).toHaveBeenCalled()
})
it('rejects publishing when a single subgraph node is not selected', async () => {
vi.mocked(comfyApp.canvas).selectedItems = new Set()
await expect(store.publishSubgraph()).rejects.toThrow(
'Must have single SubgraphNode selected to publish'
)
})
it('rejects publishing when serialization produces multiple nodes', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [subgraphNode.serialize(), subgraphNode.serialize()],
subgraphs: []
}))
await expect(store.publishSubgraph()).rejects.toThrow(
'Must have single SubgraphNode selected to publish'
)
})
it('rejects publishing when the serialized node is not a subgraph node', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas).draw = vi.fn()
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [{ ...subgraphNode.serialize(), type: 'missing' }],
subgraphs: [fromAny<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(
@@ -148,6 +239,30 @@ describe('useSubgraphStore', () => {
//check active graph
expect(comfyApp.loadGraphData).toHaveBeenCalled()
})
it('switches into the nested subgraph when editing opens a wrapper graph', async () => {
await mockFetch({ 'test.json': mockGraph })
const setGraph = vi.fn()
const nested = { id: 'nested' }
vi.mocked(comfyApp.canvas).graph = fromAny<
NonNullable<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 })
@@ -166,6 +281,12 @@ describe('useSubgraphStore', () => {
expect(second.nodes[0].id).not.toBe(-1)
expect(second.definitions!.subgraphs![0].id).toBe('123')
})
it('throws when getting an unloaded blueprint', () => {
expect(() => store.getBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')).toThrow(
'not yet loaded'
)
})
it('should identify user blueprints as non-global', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('test')).toBe(false)
@@ -188,6 +309,57 @@ 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 })
@@ -285,6 +457,203 @@ 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(
@@ -406,6 +775,29 @@ describe('useSubgraphStore', () => {
expect(nodeDef?.description).toBe('This is a test blueprint')
})
it('does not copy workflowRendererVersion into subgraph metadata on load', async () => {
await mockFetch({
'metadata-load.json': {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', extra: {} }]
},
extra: {
BlueprintDescription: 'Loaded description',
workflowRendererVersion: 'Vue'
}
}
})
const blueprint = store.getBlueprint(
BLUEPRINT_TYPE_PREFIX + 'metadata-load'
)
expect(blueprint.definitions!.subgraphs![0].extra).toEqual({
BlueprintDescription: 'Loaded description'
})
})
it('should not duplicate metadata in both workflow extra and subgraph extra when publishing', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -415,7 +807,8 @@ describe('useSubgraphStore', () => {
// Set metadata on the subgraph's extra (as the commands do)
subgraph.extra = {
BlueprintDescription: 'Test description',
BlueprintSearchAliases: ['alias1', 'alias2']
BlueprintSearchAliases: ['alias1', 'alias2'],
workflowRendererVersion: 'Vue'
}
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
@@ -464,6 +857,7 @@ describe('useSubgraphStore', () => {
const subgraphExtra = definitions.subgraphs[0]?.extra
expect(subgraphExtra?.BlueprintDescription).toBeUndefined()
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
expect(subgraphExtra?.workflowRendererVersion).toBe('Vue')
})
})

View File

@@ -39,6 +39,10 @@ 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/'
@@ -54,18 +58,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.hasPromptedSave = !confirmFirstSave
}
validateSubgraph() {
if (!this.activeState?.definitions)
validateSubgraph(): ValidSubgraphWorkflowJSON {
const activeState = this.activeState
if (!activeState?.definitions)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
const { subgraphs } = this.activeState.definitions
const { nodes } = this.activeState
const validState = activeState as ValidSubgraphWorkflowJSON
const { subgraphs } = validState.definitions
const { nodes } = validState
//Instanceof doesn't function as nodes are serialized
function isSubgraphNode(node: ComfyNode) {
return node && subgraphs.some((s) => s.id === node.type)
}
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return validState
const errors: Record<SerializedNodeId, NodeError> = {}
//mark errors for all but first subgraph node
let firstSubgraphFound = false
@@ -88,7 +94,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}
override async save(): Promise<UserFile> {
this.validateSubgraph()
const activeState = this.validateSubgraph()
if (
!this.hasPromptedSave &&
useSettingStore().get('Comfy.Workflow.WarnBlueprintOverwrite')
@@ -97,7 +103,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.hasPromptedSave = true
}
// Extract metadata from subgraph.extra to workflow.extra before saving
this.extractMetadataToWorkflowExtra()
this.extractMetadataToWorkflowExtra(activeState)
const ret = await super.save()
// Force reload to update initialState with saved metadata
registerNodeDef(await this.load({ force: true }), {
@@ -110,13 +116,14 @@ export const useSubgraphStore = defineStore('subgraph', () => {
* Moves all properties (except workflowRendererVersion) from subgraph.extra
* to workflow.extra, then removes from subgraph.extra to avoid duplication.
*/
private extractMetadataToWorkflowExtra(): void {
if (!this.activeState) return
const subgraph = this.activeState.definitions?.subgraphs?.[0]
private extractMetadataToWorkflowExtra(
activeState: ValidSubgraphWorkflowJSON
): void {
const subgraph = activeState.definitions.subgraphs?.[0]
if (!subgraph?.extra) return
const sgExtra = subgraph.extra as Record<string, unknown>
const workflowExtra = (this.activeState.extra ??= {}) as Record<
const workflowExtra = (activeState.extra ??= {}) as Record<
string,
unknown
>
@@ -129,10 +136,10 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}
override async saveAs(path: string) {
this.validateSubgraph()
const activeState = this.validateSubgraph()
this.hasPromptedSave = true
// Extract metadata from subgraph.extra to workflow.extra before saving
this.extractMetadataToWorkflowExtra()
this.extractMetadataToWorkflowExtra(activeState)
const ret = await super.saveAs(path)
// Force reload to update initialState with saved metadata
registerNodeDef(await this.load({ force: true }), {
@@ -276,8 +283,8 @@ export const useSubgraphStore = defineStore('subgraph', () => {
overrides: Partial<ComfyNodeDefV1> = {},
name: string = workflow.filename
) {
const subgraphNode = workflow.changeTracker.initialState.nodes[0]
if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint')
const subgraphNode = workflow.changeTracker.initialState
.nodes[0] as ComfyNode
subgraphNode.inputs ??= []
subgraphNode.outputs ??= []
//NOTE: Types are cast to string. This is only used for input coloring on previews

View File

@@ -6,7 +6,7 @@ import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const mockData = vi.hoisted(() => ({ isDesktop: false }))
const mockData = vi.hoisted(() => ({ isCloud: false, isDesktop: false }))
// Mock the API
vi.mock('@/scripts/api', () => ({
@@ -19,7 +19,9 @@ vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockData.isDesktop
},
isCloud: false
get isCloud() {
return mockData.isCloud
}
}))
describe('useSystemStatsStore', () => {
@@ -138,6 +140,7 @@ describe('useSystemStatsStore', () => {
describe('getFormFactor', () => {
beforeEach(() => {
// Reset systemStats for each test
mockData.isCloud = false
store.systemStats = null
})
@@ -162,6 +165,12 @@ describe('useSystemStatsStore', () => {
expect(store.getFormFactor()).toBe('other')
})
it('should return "cloud" in cloud mode', () => {
mockData.isCloud = true
expect(store.getFormFactor()).toBe('cloud')
})
describe('desktop environment', () => {
beforeEach(() => {
mockData.isDesktop = true

View File

@@ -90,6 +90,12 @@ describe('templateRankingStore', () => {
})
describe('computePopularScore', () => {
it('normalizes usage against itself before a largest score is loaded', () => {
const store = useTemplateRankingStore()
expect(store.computePopularScore('2024-01-01', 10)).toBeGreaterThan(0.8)
})
it('does not use searchRank', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100

View File

@@ -0,0 +1,25 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useExtensionStore } from '@/stores/extensionStore'
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
describe('topbarBadgeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('collects topbar badges from registered extensions', () => {
const extensionStore = useExtensionStore()
extensionStore.registerExtension({
name: 'badges',
topbarBadges: [{ text: 'Beta', label: 'BETA' }]
})
extensionStore.registerExtension({ name: 'plain' })
const store = useTopbarBadgeStore()
expect(store.badges).toEqual([{ text: 'Beta', label: 'BETA' }])
})
})

View File

@@ -116,6 +116,33 @@ describe('useUserFileStore', () => {
"Failed to load file 'file1.txt': 404 Not Found"
)
})
it('should skip loading temporary and already loaded files', async () => {
const temporaryFile = UserFile.createTemporary('draft.txt')
const loadedFile = new UserFile('file1.txt', 123, 100)
loadedFile.content = 'content'
loadedFile.originalContent = 'content'
await temporaryFile.load()
await loadedFile.load()
expect(api.getUserData).not.toHaveBeenCalled()
})
it('should force reload loaded files', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'old'
file.originalContent = 'old'
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve('new')
} as Response)
await file.load({ force: true })
expect(api.getUserData).toHaveBeenCalledWith('file1.txt')
expect(file.content).toBe('new')
})
})
describe('save', () => {
@@ -148,6 +175,60 @@ describe('useUserFileStore', () => {
expect(api.storeUserData).not.toHaveBeenCalled()
})
it('should save unmodified files when forced', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'content'
file.originalContent = 'content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve('file1.txt')
} as Response)
await file.save({ force: true })
expect(api.storeUserData).toHaveBeenCalledWith('file1.txt', 'content', {
throwOnError: true,
full_info: true,
overwrite: true
})
expect(file.lastModified).toBe(123)
expect(file.size).toBe(100)
})
it('should normalize string modified times', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'modified content'
file.originalContent = 'original content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({ modified: '2024-01-02T03:04:05Z', size: 200 })
} as Response)
await file.save()
expect(file.lastModified).toBe(
new Date('2024-01-02T03:04:05Z').getTime()
)
expect(file.size).toBe(200)
})
it('should fall back when modified time is invalid', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(999)
const file = new UserFile('file1.txt', 123, 100)
file.content = 'modified content'
file.originalContent = 'original content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ modified: 'bad date', size: 200 })
} as Response)
await file.save()
expect(file.lastModified).toBe(999)
dateNow.mockRestore()
})
})
describe('delete', () => {
@@ -161,6 +242,26 @@ describe('useUserFileStore', () => {
expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt')
})
it('should skip deleting temporary files', async () => {
const file = UserFile.createTemporary('draft.txt')
await file.delete()
expect(api.deleteUserData).not.toHaveBeenCalled()
})
it('should throw when delete fails', async () => {
const file = new UserFile('file1.txt', 123, 100)
vi.mocked(api.deleteUserData).mockResolvedValue({
status: 500,
statusText: 'Server Error'
} as Response)
await expect(file.delete()).rejects.toThrow(
"Failed to delete file 'file1.txt': 500 Server Error"
)
})
})
describe('rename', () => {
@@ -181,6 +282,41 @@ describe('useUserFileStore', () => {
expect(file.lastModified).toBe(456)
expect(file.size).toBe(200)
})
it('should rename temporary files locally', async () => {
const file = UserFile.createTemporary('draft.txt')
await file.rename('renamed.txt')
expect(api.moveUserData).not.toHaveBeenCalled()
expect(file.path).toBe('renamed.txt')
})
it('should throw when rename fails', async () => {
const file = new UserFile('file1.txt', 123, 100)
vi.mocked(api.moveUserData).mockResolvedValue({
status: 409,
statusText: 'Conflict'
} as Response)
await expect(file.rename('newfile.txt')).rejects.toThrow(
"Failed to rename file 'file1.txt': 409 Conflict"
)
})
it('should leave metadata unchanged when rename returns a string', async () => {
const file = new UserFile('file1.txt', 123, 100)
vi.mocked(api.moveUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve('newfile.txt')
} as Response)
await file.rename('newfile.txt')
expect(file.path).toBe('newfile.txt')
expect(file.lastModified).toBe(123)
expect(file.size).toBe(100)
})
})
describe('saveAs', () => {
@@ -207,6 +343,25 @@ describe('useUserFileStore', () => {
expect(newFile.size).toBe(200)
expect(newFile.content).toBe('file content')
})
it('should save temporary files in place', async () => {
const file = UserFile.createTemporary('draft.txt')
file.content = 'file content'
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ modified: 456, size: 200 })
} as Response)
const newFile = await file.saveAs('newfile.txt')
expect(api.storeUserData).toHaveBeenCalledWith(
'draft.txt',
'file content',
{ throwOnError: true, full_info: true, overwrite: false }
)
expect(newFile).toBe(file)
expect(newFile.path).toBe('draft.txt')
})
})
})
})

View File

@@ -1,61 +1,72 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUserStore } from './userStore'
const getUserConfig = vi.fn()
const apiMock = vi.hoisted(() => ({
createUser: vi.fn(),
getUserConfig: vi.fn(),
user: undefined as string | undefined
}))
vi.mock('@/scripts/api', () => ({
api: {
getUserConfig: (...args: unknown[]) => getUserConfig(...args)
}
api: apiMock
}))
describe('userStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
getUserConfig.mockReset()
setActivePinia(createTestingPinia({ stubActions: false }))
apiMock.createUser.mockReset()
apiMock.getUserConfig.mockReset()
apiMock.user = undefined
localStorage.clear()
})
describe('initialize', () => {
it('returns an empty user list before initialization', () => {
const store = useUserStore()
expect(store.users).toEqual([])
})
it('fetches user config on first call', async () => {
getUserConfig.mockResolvedValue({})
apiMock.getUserConfig.mockResolvedValue({})
const store = useUserStore()
await store.initialize()
expect(getUserConfig).toHaveBeenCalledTimes(1)
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
expect(store.initialized).toBe(true)
})
it('is a no-op once already initialized', async () => {
getUserConfig.mockResolvedValue({})
apiMock.getUserConfig.mockResolvedValue({})
const store = useUserStore()
await store.initialize()
getUserConfig.mockClear()
apiMock.getUserConfig.mockClear()
await store.initialize()
expect(getUserConfig).not.toHaveBeenCalled()
expect(apiMock.getUserConfig).not.toHaveBeenCalled()
})
it('retries on a subsequent call when the first fetch failed', async () => {
getUserConfig.mockRejectedValueOnce(new Error('network down'))
getUserConfig.mockResolvedValueOnce({})
apiMock.getUserConfig.mockRejectedValueOnce(new Error('network down'))
apiMock.getUserConfig.mockResolvedValueOnce({})
const store = useUserStore()
await expect(store.initialize()).rejects.toThrow('network down')
expect(store.initialized).toBe(false)
await expect(store.initialize()).resolves.toBeUndefined()
expect(getUserConfig).toHaveBeenCalledTimes(2)
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(2)
expect(store.initialized).toBe(true)
})
it('deduplicates concurrent calls before the first fetch resolves', async () => {
let resolveConfig: (value: unknown) => void = () => {}
getUserConfig.mockImplementation(
apiMock.getUserConfig.mockImplementation(
() =>
new Promise((resolve) => {
resolveConfig = resolve
@@ -68,7 +79,100 @@ describe('userStore', () => {
resolveConfig({})
await Promise.all([a, b])
expect(getUserConfig).toHaveBeenCalledTimes(1)
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
})
it('derives multi-user state and restores the current user from storage', async () => {
localStorage['Comfy.userId'] = 'user-2'
apiMock.getUserConfig.mockResolvedValue({
users: { 'user-1': 'Ada', 'user-2': 'Grace' }
})
const store = useUserStore()
await store.initialize()
expect(store.isMultiUserServer).toBe(true)
expect(store.needsLogin).toBe(false)
expect(store.users).toEqual([
{ userId: 'user-1', username: 'Ada' },
{ userId: 'user-2', username: 'Grace' }
])
expect(store.currentUser).toEqual({ userId: 'user-2', username: 'Grace' })
await vi.waitFor(() => expect(apiMock.user).toBe('user-2'))
})
it('requires login on multi-user servers without a stored user', async () => {
apiMock.getUserConfig.mockResolvedValue({
users: { 'user-1': 'Ada' }
})
const store = useUserStore()
await store.initialize()
expect(store.needsLogin).toBe(true)
expect(store.currentUser).toBeNull()
expect(apiMock.user).toBeUndefined()
})
})
describe('createUser', () => {
it('returns the created user id with the requested username', async () => {
apiMock.createUser.mockResolvedValue({
json: () => Promise.resolve('user-1'),
status: 201
})
const store = useUserStore()
await expect(store.createUser('Ada')).resolves.toEqual({
userId: 'user-1',
username: 'Ada'
})
})
it('throws API errors returned by user creation', async () => {
apiMock.createUser.mockResolvedValue({
json: () => Promise.resolve({ error: 'name taken' }),
status: 409,
statusText: 'Conflict'
})
const store = useUserStore()
await expect(store.createUser('Ada')).rejects.toThrow('name taken')
})
it('throws a fallback error when user creation has no error body', async () => {
apiMock.createUser.mockResolvedValue({
json: () => Promise.resolve({}),
status: 500,
statusText: 'Server Error'
})
const store = useUserStore()
await expect(store.createUser('Ada')).rejects.toThrow(
'Error creating user: 500 Server Error'
)
})
})
describe('login/logout', () => {
it('persists login identity and clears it on logout', async () => {
const store = useUserStore()
await store.login({ userId: 'user-1', username: 'Ada' })
expect(localStorage['Comfy.userId']).toBe('user-1')
expect(localStorage['Comfy.userName']).toBe('Ada')
await store.logout()
expect(localStorage['Comfy.userId']).toBeUndefined()
expect(localStorage['Comfy.userName']).toBeUndefined()
})
it('does not set api.user when login happens before user config loads', async () => {
const store = useUserStore()
await store.login({ userId: 'user-1', username: 'Ada' })
expect(apiMock.user).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,258 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { toNodeId } from '@/types/nodeId'
const { mockState } = vi.hoisted(() => ({
mockState: {
graph: null as { extra: Record<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)
})
})

View File

@@ -0,0 +1,115 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const storeMocks = vi.hoisted(() => ({
apiKeyAuthStore: {
isAuthenticated: false
},
authStore: {
currentUser: null as null | { uid: string }
},
commandStore: {
commands: [],
execute: vi.fn()
},
executionErrorStore: {
lastExecutionError: null,
lastNodeErrors: null
},
queueSettingsStore: {},
settingStore: {
settingsById: {},
get: vi.fn(),
set: vi.fn()
},
sidebarTabStore: {
registerSidebarTab: vi.fn(),
unregisterSidebarTab: vi.fn(),
sidebarTabs: []
},
toastStore: {},
workflowStore: {}
}))
vi.mock('@vueuse/core', () => ({
useMagicKeys: () => ({ shift: false })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => storeMocks.settingStore
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => storeMocks.toastStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => storeMocks.workflowStore
}))
vi.mock('@/services/colorPaletteService', () => ({
useColorPaletteService: () => ({})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => storeMocks.apiKeyAuthStore
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => storeMocks.authStore
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => storeMocks.commandStore
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => storeMocks.executionErrorStore
}))
vi.mock('@/stores/queueStore', () => ({
useQueueSettingsStore: () => storeMocks.queueSettingsStore
}))
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
useBottomPanelStore: () => ({})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => storeMocks.sidebarTabStore
}))
describe('useWorkspaceStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
storeMocks.apiKeyAuthStore.isAuthenticated = false
storeMocks.authStore.currentUser = null
})
it('reports logged out when neither auth source is active', () => {
const store = useWorkspaceStore()
expect(store.user.isLoggedIn).toBe(false)
})
it('reports logged in for API-key auth', () => {
storeMocks.apiKeyAuthStore.isAuthenticated = true
const store = useWorkspaceStore()
expect(store.user.isLoggedIn).toBe(true)
})
it('reports logged in for Firebase auth', () => {
storeMocks.authStore.currentUser = { uid: 'user-1' }
const store = useWorkspaceStore()
expect(store.user.isLoggedIn).toBe(true)
})
})

147
src/utils/fuseUtil.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest'
import type { FuseSearchable } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
interface SearchItem extends Partial<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
)
})
})

View File

@@ -0,0 +1,44 @@
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'
)
})
})

View File

@@ -1,14 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createNode, getWidgetIdForNode, resolveNode } from './litegraphUtil'
import {
addToComboValues,
compressWidgetInputSlots,
createNode,
executeWidgetsCallback,
getItemsColorOption,
getLinkTypeColor,
getWidgetIdForNode,
isAnimatedOutput,
isAudioNode,
isImageNode,
isLoad3dNode,
isVideoNode,
isVideoOutput,
migrateWidgetsValues,
resolveComboValues,
resolveNode,
resolveNodeWidget
} from './litegraphUtil'
const mockBringNodeToFront = vi.fn()
@@ -191,3 +216,233 @@ describe('getWidgetIdForNode', () => {
expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined()
})
})
describe('media helpers', () => {
it('classifies preview media nodes', () => {
expect(isImageNode(undefined)).toBe(false)
expect(isVideoNode(undefined)).toBe(false)
expect(isAudioNode(undefined)).toBe(false)
const imageNode = new LGraphNode('Image')
imageNode.previewMediaType = 'image'
const imageWithImgs = Object.assign(new LGraphNode('Image'), {
previewMediaType: 'model' as const,
imgs: [document.createElement('img')]
})
const videoWithImgs = Object.assign(new LGraphNode('Video'), {
previewMediaType: 'video' as const,
imgs: [document.createElement('img')]
})
const videoNode = new LGraphNode('Video')
videoNode.previewMediaType = 'video'
const videoContainerNode = Object.assign(new LGraphNode('Video'), {
videoContainer: document.body
})
const audioNode = new LGraphNode('Audio')
audioNode.previewMediaType = 'audio'
expect(isImageNode(imageNode)).toBe(true)
expect(isImageNode(imageWithImgs)).toBe(true)
expect(isImageNode(videoWithImgs)).toBe(false)
expect(isVideoNode(videoNode)).toBe(true)
expect(isVideoNode(videoContainerNode)).toBe(true)
expect(isAudioNode(audioNode)).toBe(true)
})
it('distinguishes animated images from video outputs', () => {
expect(isAnimatedOutput(undefined)).toBe(false)
expect(isAnimatedOutput({ animated: [false, true] })).toBe(true)
expect(
isVideoOutput({
animated: [true],
images: [{ filename: 'clip.mp4' }]
})
).toBe(true)
expect(
isVideoOutput({
animated: [true],
images: [{ filename: 'preview.webp' }]
})
).toBe(false)
expect(
isVideoOutput({
animated: [true],
images: [{ filename: 'preview.png' }]
})
).toBe(false)
})
it('detects 3d loader nodes', () => {
const modelNode = new LGraphNode('Load3D')
modelNode.type = 'Load3D'
const animationNode = new LGraphNode('Load3DAnimation')
animationNode.type = 'Load3DAnimation'
const imageNode = new LGraphNode('LoadImage')
imageNode.type = 'LoadImage'
expect(isLoad3dNode(modelNode)).toBe(true)
expect(isLoad3dNode(animationNode)).toBe(true)
expect(isLoad3dNode(imageNode)).toBe(false)
})
})
describe('combo widget helpers', () => {
function combo(values: IComboWidget['options']['values']): IComboWidget {
return fromPartial<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([])
})
})

View File

@@ -0,0 +1,82 @@
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')
})
})

View File

@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { whileMouseDown } from '@/utils/mouseDownUtil'
describe('whileMouseDown', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs until the element receives mouseup', () => {
const element = document.createElement('button')
const callback = vi.fn()
whileMouseDown(element, callback, 10)
vi.advanceTimersByTime(25)
element.dispatchEvent(new MouseEvent('mouseup'))
vi.advanceTimersByTime(30)
expect(callback.mock.calls).toEqual([[0], [1]])
})
it('uses the event target and stops on document mouseup', () => {
const element = document.createElement('button')
const event = new MouseEvent('mousedown')
Object.defineProperty(event, 'target', { value: element })
const callback = vi.fn()
whileMouseDown(event, callback, 5)
vi.advanceTimersByTime(12)
document.dispatchEvent(new MouseEvent('mouseup'))
vi.advanceTimersByTime(20)
expect(callback.mock.calls).toEqual([[0], [1]])
})
})

View File

@@ -0,0 +1,46 @@
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'
)
})
})

View File

@@ -0,0 +1,47 @@
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')
})
})

View File

@@ -0,0 +1,290 @@
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
})
})
})

View File

@@ -0,0 +1,67 @@
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)
})
})

View File

@@ -1,7 +1,21 @@
import { describe, expect, it } from 'vitest'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { buildTree, sortedTree } from '@/utils/treeUtil'
import {
buildTree,
combineTrees,
findNodeByKey,
flattenTree,
sortedTree,
unwrapTreeRoot
} from '@/utils/treeUtil'
const createNode = (label: string, leaf = false): TreeNode => ({
key: label,
label,
leaf,
children: []
})
describe('buildTree', () => {
it('should handle empty folder items correctly', () => {
@@ -65,14 +79,101 @@ describe('buildTree', () => {
})
})
describe('sortedTree', () => {
const createNode = (label: string, leaf = false): TreeNode => ({
key: label,
label,
leaf,
children: []
describe('unwrapTreeRoot', () => {
it('promotes the single non-leaf folder child', () => {
const tree: TreeNode = {
key: 'root',
label: 'root',
children: [
{
key: 'root/a',
label: 'a',
leaf: false,
children: [createNode('child', true)]
}
]
}
expect(unwrapTreeRoot(tree).children?.map((node) => node.key)).toEqual([
'child'
])
})
it('keeps roots with leaf, empty, or multiple children intact', () => {
const leafRoot: TreeNode = {
key: 'root',
label: 'root',
children: [createNode('leaf', true)]
}
const emptyFolderRoot: TreeNode = {
key: 'root',
label: 'root',
children: [createNode('folder')]
}
const multiRoot: TreeNode = {
key: 'root',
label: 'root',
children: [createNode('a'), createNode('b')]
}
const childWithoutChildren: TreeNode = {
key: 'root',
label: 'root',
children: [
{
key: 'root/a',
label: 'a',
leaf: false
}
]
}
expect(unwrapTreeRoot(leafRoot)).toBe(leafRoot)
expect(unwrapTreeRoot(emptyFolderRoot)).toBe(emptyFolderRoot)
expect(unwrapTreeRoot(multiRoot)).toBe(multiRoot)
expect(unwrapTreeRoot(childWithoutChildren)).toBe(childWithoutChildren)
})
})
describe('flattenTree', () => {
it('returns data from leaf nodes only', () => {
const tree: TreeNode = {
key: 'root',
label: 'root',
children: [
{
key: 'folder',
label: 'folder',
children: [
{
key: 'leaf-a',
label: 'leaf-a',
leaf: true,
data: { path: 'a' }
},
{
key: 'leaf-b',
label: 'leaf-b',
leaf: true
}
]
},
{
key: 'leaf-c',
label: 'leaf-c',
leaf: true,
data: { path: 'c' }
}
]
}
expect(flattenTree<{ path: string }>(tree)).toEqual([
{ path: 'c' },
{ path: 'a' }
])
})
})
describe('sortedTree', () => {
it('should return a new node instance', () => {
const node = createNode('root')
const result = sortedTree(node)
@@ -92,6 +193,25 @@ describe('sortedTree', () => {
expect(result.children?.map((c) => c.label)).toEqual(['a', 'b', 'c'])
})
it('sorts children with missing labels by the empty-label fallback', () => {
const unlabeled = {
key: 'missing',
label: undefined as unknown as string,
leaf: true
}
const node: TreeNode = {
key: 'root',
label: 'root',
leaf: false,
children: [unlabeled, createNode('a', true)]
}
expect(sortedTree(node).children?.map((c) => c.key)).toEqual([
'missing',
'a'
])
})
describe('with groupLeaf=true', () => {
it('should group folders before files', () => {
const node: TreeNode = {
@@ -110,6 +230,35 @@ describe('sortedTree', () => {
expect(labels).toEqual(['folder1', 'folder2', 'another.txt', 'file.txt'])
})
it('sorts grouped children with missing labels', () => {
const unlabeledFolder = {
key: 'folder-missing',
label: undefined as unknown as string,
leaf: false,
children: []
}
const unlabeledFile = {
key: 'file-missing',
label: undefined as unknown as string,
leaf: true,
children: []
}
const node: TreeNode = {
key: 'root',
label: 'root',
children: [
createNode('folder-b'),
unlabeledFolder,
createNode('file-b', true),
unlabeledFile
]
}
expect(
sortedTree(node, { groupLeaf: true }).children?.map((c) => c.key)
).toEqual(['folder-missing', 'folder-b', 'file-missing', 'file-b'])
})
it('should sort recursively', () => {
const node: TreeNode = {
key: 'root',
@@ -145,3 +294,54 @@ describe('sortedTree', () => {
expect(result).toEqual(node)
})
})
describe('findNodeByKey', () => {
it('returns the matching nested node or null', () => {
const child = createNode('root/child')
const tree: TreeNode = {
key: 'root',
label: 'root',
children: [child]
}
expect(findNodeByKey(tree, 'root')).toBe(tree)
expect(findNodeByKey(tree, 'root/child')).toBe(child)
expect(findNodeByKey(tree, 'missing')).toBeNull()
expect(findNodeByKey(createNode('root'), 'missing')).toBeNull()
})
})
describe('combineTrees', () => {
it('adds a cloned subtree under its matching parent', () => {
const root: TreeNode = {
key: 'root',
label: 'root',
children: [{ key: 'root/a', label: 'a', children: [] }]
}
const subtree: TreeNode = {
key: 'root/a/b',
label: 'b',
leaf: true,
data: { path: 'b' }
}
const combined = combineTrees(root, subtree)
expect(combined).not.toBe(root)
expect(combined.children?.[0].children?.[0]).toEqual(subtree)
expect(combined.children?.[0].children?.[0]).not.toBe(subtree)
expect(root.children?.[0].children).toEqual([])
})
it('returns a clone unchanged when the parent key is absent', () => {
const root: TreeNode = { key: 'root', label: 'root' }
const combined = combineTrees(root, {
key: 'root/missing/leaf',
label: 'leaf',
leaf: true
})
expect(combined).toEqual(root)
expect(combined).not.toBe(root)
})
})

View File

@@ -148,7 +148,7 @@ function cloneTree<T extends TreeNode>(node: T): T {
const clone = { ...node }
// Clone children recursively
if (node.children && node.children.length > 0) {
if (node.children) {
clone.children = node.children.map((child) => cloneTree(child))
}

Some files were not shown because too many files have changed in this diff Show More