Compare commits

..

18 Commits

Author SHA1 Message Date
Austin
223831c514 Add test 2026-04-29 15:40:25 -07:00
Austin
bbbb55c410 Allow uploading workflows to library by dnd 2026-04-29 15:40:25 -07:00
Benjamin Lu
9e16390c33 test: assert core command help urls (#11768)
## Summary

- Tighten the new `useCoreCommands` help command tests to assert the
exact external URL opened for GitHub issues and Discord.

## Testing

```bash
pnpm test:unit -- src/composables/useCoreCommands.test.ts
pnpm format:check src/composables/useCoreCommands.test.ts
```

Also passed pre-commit `pnpm typecheck` and push hook `pnpm knip`.

Stacked on #11748.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11768-test-assert-core-command-help-urls-3516d73d365081de99d0c71f707d0fb4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante01yoon <bunggl@naver.com>
2026-04-29 21:52:19 +00:00
Terry Jia
c88275b2a4 test(load3d): add unit tests for SceneManager (#11762)
## Summary

Add 35 unit tests for `SceneManager`, the largest remaining gap in the
load3d core (452 LOC). Targets only the logic-bearing methods
(background mode dispatch, render-mode switching, aspect-ratio scaling,
capture pipeline, dispose). Renderer-passthrough internals are
intentionally left to E2E. Follow-up to Tier 1 (#11733), Tier 2, Tier
3a, and Tier 3b.

## Changes

- **What**: 35 new tests covering construction (main scene + background
scene + grid + tiled mesh + default color mode), `toggleGrid`,
`setBackgroundColor` (color update, scene-bg cleanup, panorama-demote,
prior-texture dispose), `setBackgroundImage` (empty-path fallback,
loading-start emit, temp/output subfolder rewrite, /api prefix,
tiled-mesh material swap, panorama scene-background promotion,
prior-texture dispose, error-path fallback), `removeBackgroundImage`,
`setBackgroundRenderMode` (no-op same-mode, color-only emit, image
panorama-promote, image tiled-demote), `updateBackgroundSize`
(no-texture/no-mesh/no-map guards, wide vs. tall image scaling),
`handleResize` (image-bg active vs. color-only),
`getCurrentBackgroundInfo`, `captureScene` (returns 3 data URLs +
restores renderer state, restores grid visibility, propagates errors),
and `dispose` (resource cleanup + scene-background null).

## Review Focus

- **Coverage**: `SceneManager.ts` 89.5% lines / 74.2% branches / 89.5%
funcs. Uncovered lines are concentrated in `renderBackground` and the
deep mesh-traversal loop inside `captureScene` — exactly the
renderer-passthrough territory deferred per the Tier 3c plan.
- **`THREE.Material.needsUpdate` is a write-only setter** in THREE.js —
reading returns `undefined`. The "demote panorama → tiled" test asserts
`mat.map === texture` instead of `mat.needsUpdate === true`, with a
comment explaining why.
- **happy-dom canvas `clientWidth`/`clientHeight` default to 0** —
`makeRenderer()` overrides them via `Object.defineProperty` so
production code reading `renderer.domElement.clientWidth` gets the test
value.
- **`THREE.TextureLoader` is mocked via `vi.mock('three', ...)` with
`importOriginal`**, matching the pattern in `RecordingManager.test.ts`
and `HDRIManager.test.ts`. `mockTextureLoad` is hoisted so each test can
resolve/reject the load callback independently.
- **`vi.spyOn(manager, 'setBackgroundColor')` in three places** to
assert internal delegation (empty-path fallback, error fallback,
`removeBackgroundImage`). Defensible because the delegation IS the
documented contract for these methods.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11762-test-load3d-add-unit-tests-for-SceneManager-3516d73d365081628ff4c146defebac1)
by [Unito](https://www.unito.io)
2026-04-29 17:54:21 -04:00
pythongosssss
23e48b2140 feat: Node search - Improve category tree on mobile with collapse (#11687)
## Summary

Improves the search experience on mobile by collapsing the category menu
& reogranises the filer buttons

## Changes

- **What**: 
- add toggle button to collapse category selection
- auto collapse on mobile
- floating panel on mobile
- re-order filter buttons
- tests

## Screenshots (if applicable)
Closed:
<img width="415" height="373" alt="image"
src="https://github.com/user-attachments/assets/c99cd6cd-eb92-4ce3-9844-591dd1e80769"
/>

Desktop open:
<img width="455" height="328" alt="image"
src="https://github.com/user-attachments/assets/df15bdda-f77a-4c12-90e1-8608d67c55b4"
/>

Mobile open:
<img width="427" height="600" alt="image"
src="https://github.com/user-attachments/assets/a2b115ad-bce0-4ed1-9d30-126a35263259"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11687-feat-Node-search-Improve-category-tree-on-mobile-with-collapse-34f6d73d365081729075e8b0071a3bc1)
by [Unito](https://www.unito.io)
2026-04-29 20:52:58 +00:00
Terry Jia
af43619ae1 test(load3d): add unit tests for copyLoad3dState in load3dService (#11761)
## Summary

Add 19 unit tests for `Load3dService.copyLoad3dState` (the one method
intentionally deferred from Tier 1). Brings `load3dService.ts` from
54.5% to 100% line coverage. Follow-up to Tier 1 (#11733), Tier 2, and
Tier 3a.

## Changes

- **What**: 19 new tests covering every branch of `copyLoad3dState`:
no-source-model fast path, splat fast path (with and without
`originalURL`), mesh path (existing-target-model removal, SkeletonUtils
clone, originalModel/material/upDirection/texture copy, initial
transform on clone, gizmo transform application, gizmo enable/disable
across both source and target prior states, animation copy when
present/absent), background-image vs. background-color dispatch,
light-intensity falsy fallback, perspective-vs-orthographic FOV gating,
and the always-detach + setupForModel gizmo contract.

## Review Focus

- **Coverage**: `load3dService.ts` lines 54.5% → **100%**, branches 50%
→ **90.9%**, funcs 88.9% → **100%**. Remaining uncovered lines are minor
(`loadSkeletonUtils` cache-hit path, a couple of null-map early
returns).
- **Test fixtures use real `THREE.Object3D` and `THREE.Scene`** so
production code's `.position.set(...)`, `.rotation.set(...)`,
`scene.add/remove` calls work without further stubbing.
- **`makeTarget` memoizes the gizmo manager** (`getGizmoManager: () =>
gizmoManager` rather than returning a fresh literal each call).
Production code calls `getGizmoManager()` multiple times; without
memoization, the `detach` and `setupForModel` mocks would be
unobservable from tests.
- **`state` return on `makeTarget`** exposes mutable `modelManager`,
captured `gizmoManager`/`animationManager`, and
`sceneAdded`/`sceneRemoved` arrays so tests can assert post-state
directly without casts through the production-typed `Load3d` interface.
- **Background-image test uses `createMockLGraphNode({ id, properties
})` overrides** rather than mid-test property mutation.
- **Destructuring-default gotcha**: `const { lightsIntensity = 0.8 } =
overrides` applies the default even when `undefined` is passed
explicitly. The "fallback to setLightIntensity(1)" test passes `0`
instead — production code's `intensity || 1` short-circuits the same
way.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11761-test-load3d-add-unit-tests-for-copyLoad3dState-in-load3dService-3516d73d36508142bc72d97b27b0a36b)
by [Unito](https://www.unito.io)
2026-04-29 16:51:26 -04:00
Terry Jia
d2e88011aa test(load3d): add unit tests for EventManager, ViewHelperManager, and load3dLazy (#11760)
## Summary

Add unit tests for the three remaining small/wrapper modules in the
load3d domain (`EventManager` pub/sub, `ViewHelperManager` ViewHelper
wrapper, and `load3dLazy` lazy-extension loader). Follow-up to Tier 1
(#11733) and Tier 2.

## Changes

- **What**: 28 new unit tests across 3 files covering EventManager
add/remove/emit semantics, ViewHelperManager container DOM creation +
pointer-event interception + animation-finished camera-state emission +
perspective/orthographic zoom snapshotting + dispose ordering, and
load3dLazy extension registration + 3D-node-type recognition +
Load3D-specific `mesh_upload` injection + `beforeRegisterNodeDef` hook
replay for newly registered extensions.

## Review Focus

- **Coverage** (lines/branches/funcs): EventManager 100% / 100% / 100%,
ViewHelperManager 100% / 83.3% / 72.7%, load3dLazy 95.8% / 80% / 100%.
Aggregate: **98.6% / 85.3% / 85.7%**.
- **`vi.mock` factory side-effects only fire once per test file** —
`load3dLazy.test.ts` originally tried to count dynamic imports of
`./load3d` and `./saveMesh` via spies inside the mock factory, but
factories aren't re-invoked across `vi.resetModules()`. Switched to
verifying observable side effects (`enabledExtensions` getter call
counts, `beforeRegisterNodeDef` replay invocations).
- **Snapshot-vs-diff `enabledExtensions` queue** in
`load3dLazy.test.ts`: production code does `before = new
Set(enabledExtensions); await imports; diff =
enabledExtensions.filter(!before.has)`. To exercise the replay branch,
the mock returns `[]` first (for `before`) and `[newExtension]` second
(for the post-import snapshot) via `mockReturnValueOnce` queueing.
- **`MockViewHelper` is defined inside the `vi.mock()` factory** rather
than `vi.hoisted()`, matching the `GizmoManager.test.ts:15-41`
convention (hoisted handles only, classes inside the factory).
- **PointerEvent propagation tests** require the production code's
`event.stopPropagation()` to actually keep events from bubbling to the
parent in happy-dom; the parent listener gets attached and
asserted-not-called.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11760-test-load3d-add-unit-tests-for-EventManager-ViewHelperManager-and-load3dLazy-3516d73d3650814bb2dac3360ab0d2a1)
by [Unito](https://www.unito.io)
2026-04-29 16:50:48 -04:00
Terry Jia
180a0001e8 test(load3d): add unit tests for LightingManager, ControlsManager, exportMenuHelper, and ModelExporter (#11758)
## Summary

Add unit tests for the four Tier 2 untested logic modules in the load3d
domain (`LightingManager`, `ControlsManager`, `exportMenuHelper`, and
`ModelExporter`). Follow-up to the Tier 1 PR (#11733).

## Changes

- **What**: 43 new unit tests across 4 files covering light
setup/intensity scaling/HDRI mode/disposal, OrbitControls construction
(including DOM-parent fallback) and camera-state event emission, the
export submenu builder (item structure, submenu opening, format
dispatch, success/error toasts), and the static `ModelExporter` (URL
parsing, direct-URL fast paths for matching extensions, GLB/OBJ/STL
serialization branches, error toast paths).

## Review Focus

- **Coverage** (lines/branches/funcs): LightingManager 100% / 50% /
90.9%, ControlsManager 100% / 100% / 87.5%, exportMenuHelper 100% / 100%
/ 100%, ModelExporter 98.4% / 95.7% / 100%. Aggregate: **99.2% / 93.5% /
95.1%**.
- **`vi.mock(import('@/lib/litegraph/src/litegraph'), ...)` in
`exportMenuHelper.test.ts`** uses the dynamic-import form so
`importOriginal()` is auto-typed. Required because `apiSchema.ts`
transitively imports `LinkMarkerShape` from the same module — replacing
the whole module breaks the build. The mock replaces only
`LiteGraph.ContextMenu` in-place on the real singleton.
- **`MockContextMenu` must be a class**, not an arrow function —
production code does `new LiteGraph.ContextMenu(...)`. Initial
arrow-function mock failed with "is not a constructor".
- **Fake-timer rejection pattern in `ModelExporter.test.ts`**: rejection
assertions are attached *before* `vi.runAllTimersAsync()` (`const
assertion = expect(p).rejects.toThrow(...); await drain; await
assertion`) to avoid unhandled-rejection warnings.
- **Surprising `detectFormatFromURL` behavior**:
`detectFormatFromURL('?filename=cube')` returns `'cube'`, not `null`,
because `'cube'.split('.').pop()` returns the whole basename when no dot
is present. Test documents this rather than asserting an incorrect
expectation.
- **Two unreachable lines left uncovered**: `LightingManager:65` (`?? 1`
fallback in the `setLightIntensity` multiplier lookup — every light is
registered in the map at construction, so the fallback is dead) and
`ModelExporter:21` (a `try/catch` around `URLSearchParams` whose
constructor cannot throw on the inputs the production code passes).

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11758-test-load3d-add-unit-tests-for-LightingManager-ControlsManager-exportMenuHelper-and-3516d73d365081cb96fff33672503822)
by [Unito](https://www.unito.io)
2026-04-29 16:50:06 -04:00
Dante
8f011225bf test: add unit tests for useCoreCommands canvas/help commands (#11748)
## Summary

Adds 8 tests across three new describe blocks for
\`src/composables/useCoreCommands.ts\`:

- **Canvas view**: \`Comfy.Canvas.ResetView\`, \`Comfy.Canvas.ZoomIn\`,
\`Comfy.Canvas.ZoomOut\`.
- **Workflow lifecycle**: \`Comfy.OpenClipspace\`,
\`Comfy.RefreshNodeDefinitions\`.
- **Help**: \`Comfy.Help.OpenComfyUIIssues\`,
\`Comfy.Help.OpenComfyOrgDiscord\`, \`Comfy.Help.AboutComfyUI\`.

Adds \`vi.hoisted\` mocks for \`useTelemetry\`, \`useSettingsDialog\`,
and \`useLitegraphService.resetView\` so they remain isolated from the
existing 15-test suite.

## Why this slice

\`useCoreCommands.ts\` exports 118 distinct command callbacks (1356
LOC). A single coverage-backfill PR for the whole file would be unwieldy
and risk merge conflicts (this file is touched frequently). This PR
covers a coherent slice — view/lifecycle/help commands — and follow-up
PRs can pick off remaining clusters.

## Testing

\`\`\`bash
pnpm vitest run src/composables/useCoreCommands.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11748-test-add-unit-tests-for-useCoreCommands-canvas-help-commands-3516d73d365081c384ffcc72c15dfd47)
by [Unito](https://www.unito.io)
2026-04-29 13:23:07 -07:00
pythongosssss
3c50487c18 test: add test for MaxHistoryItems setting (#11750)
## Summary

Adds test to ensure MaxHistoryItems limits both API query & client
rendering

## Changes

- **What**: 
- ensure limit is added to url
- ensure virtual grid is capped to limit

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11750-test-add-test-for-MaxHistoryItems-setting-3516d73d365081daa973ccfd8a7f479e)
by [Unito](https://www.unito.io)
2026-04-29 19:10:31 +00:00
pythongosssss
818e549e8e fix: hide blueprint node id in search (#11759)
There is a setting that enables Node IDs to display on the search
results.
Subgraphs have long non-user friendly IDs which cause this to render
badly for the built in blueprints (and user published). This update
hides the IDs for blueprint nodes.

## Changes

- **What**: 
- hide if blueprint
- add test

## Screenshots (if applicable)

Before:
<img width="910" height="504" alt="image"
src="https://github.com/user-attachments/assets/9eea9fd7-8f72-4e1b-9522-46efba0ef71a"
/>

After:
<img width="797" height="552" alt="image"
src="https://github.com/user-attachments/assets/43d6fc62-4102-41c3-b9bb-a3efd244580d"
/>

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11759-fix-hide-blueprint-node-id-in-search-3516d73d365081baa055d12c6a31fadd)
by [Unito](https://www.unito.io)
2026-04-29 17:08:54 +00:00
AustinMroz
fd1a8e9432 Fix legacy widget width in app mode (#11574)
When in app mode, widgets can be drawn with size different from the size
of the parent node. Mouse events on legacy canvas widgets require that
the client code query the current state of the node and widget to
determine if any elements are being interacted with. This PR sets the
`widget.width` property when a legacy canvas widget draw operation
occurs so that custom nodes can properly resolve subsequent mouse
events.

At current, no core nodes exist that utilize legacy widgets. As a result
the setup code to test this bug fix is slightly involved.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11574-Fix-legacy-widget-width-in-app-mode-34b6d73d365081caaa34c6204f8361f6)
by [Unito](https://www.unito.io)
2026-04-29 09:31:57 -07:00
Dante
8f61ecd82e test: add unit tests for executionStore WebSocket handlers (#11746)
## Summary

Adds 22 new tests for \`src/stores/executionStore.ts\`, raising coverage
from **48.7% → 82.9%** lines (functions 47% → 72.5%). Tests drive each
WebSocket handler by capturing handlers registered through the mocked
\`api.addEventListener\` and dispatching CustomEvents at them.

## Test Coverage

WebSocket handlers (driven through \`bindExecutionEvents\`):
- \`execution_start\` — sets activeJobId, seeds queued job entry, clears
initializing state for the starting job.
- \`execution_cached\` — marks listed nodes; no-op when no active job.
- \`execution_interrupted\` — clears active job state.
- \`executed\` — marks executed node; no-op when no active job.
- \`execution_success\` — clears active job and progress state.
- \`executing\` — clears \`_executingNodeProgress\` and activeJobId on
null detail.
- \`progress\` — sets \`_executingNodeProgress\`.
- \`status\` — reads clientId once and stops listening.
- \`execution_error\` — service-level (no node_id) routes to
\`lastPromptError\`; runtime errors route to \`lastExecutionError\`.
- \`notification\` — marks job as initializing on "Waiting for a
machine"; ignores empty id and unrelated text.

Other:
- \`unbindExecutionEvents\` removes every listener registered.
- \`storeJob\` populates queuedJobs, jobIdToWorkflowId,
jobIdToSessionWorkflowPath.
- \`registerJobWorkflowIdMapping\` ignores empty inputs.
- \`ensureSessionWorkflowPath\` is idempotent and updates on change.

## Testing

\`\`\`bash
pnpm vitest run src/stores/executionStore.test.ts
pnpm vitest run src/stores/executionStore.test.ts --coverage
--coverage.include='src/stores/executionStore.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11746-test-add-unit-tests-for-executionStore-WebSocket-handlers-3516d73d3650810aa910f5a022fdc17b)
by [Unito](https://www.unito.io)
2026-04-29 08:29:59 -04:00
Dante
8fe0385a57 test: add unit tests for pnginfo wrappers and getLatentMetadata (#11745)
## Summary

Extends \`src/scripts/pnginfo.test.ts\` with 5 new tests covering the
format-specific delegating wrappers and the safetensors metadata reader.
Lifts pnginfo.ts coverage from **17.6% → 23.2%** lines (the remaining
gap is \`importA1111\`, which needs a refactor before it can be tested
cleanly — left to a follow-up).

## Test Coverage

- \`getPngMetadata\`, \`getFlacMetadata\`, \`getAvifMetadata\` delegate
to their respective \`metadata/*\` modules (mocked).
- \`getLatentMetadata\` returns the \`__metadata__\` object from a
hand-built safetensors header.
- \`getLatentMetadata\` resolves \`undefined\` when the header has no
\`__metadata__\` entry.

## Out of scope

\`importA1111\` (lines 176-542) is a 270-line A1111-prompt →
ComfyUI-graph builder. Testing it requires either heavy LiteGraph mocks
or a refactor that extracts pure parsing helpers from the graph-mutation
code. Tracking separately.

## Testing

\`\`\`bash
pnpm vitest run src/scripts/pnginfo.test.ts
pnpm vitest run src/scripts/pnginfo.test.ts --coverage
--coverage.include='src/scripts/pnginfo.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11745-test-add-unit-tests-for-pnginfo-wrappers-and-getLatentMetadata-3516d73d365081c080a6c8146aa1bee8)
by [Unito](https://www.unito.io)
2026-04-29 08:25:26 -04:00
Dante
d078af3a79 test: add unit tests for avif metadata parser (#11744)
## Summary

Adds 12 tests for `src/scripts/metadata/avif.ts`, raising line coverage
from **2.3% → 90.4%** (statements 88.5%, functions 93.3%).

## Test Coverage

Happy paths:
- Workflow JSON extracted from EXIF Exif item (LE)
- Prompt JSON extracted
- Big-endian (MM) EXIF parsing
- Both prompt and workflow present in separate EXIF entries

Negative paths (each yields `{}` without throwing):
- AVIF major brand is not "avif"
- Meta box missing
- iinf has no Exif item
- EXIF entry uses an unrecognized key
- EXIF entry has malformed JSON
- infe version is unsupported (1)
- iloc box missing while iinf has Exif
- Buffer too short for valid header

## Testing

\`\`\`bash
pnpm vitest run src/scripts/metadata/avif.test.ts
pnpm vitest run src/scripts/metadata/avif.test.ts --coverage
--coverage.include='src/scripts/metadata/avif.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11744-test-add-unit-tests-for-avif-metadata-parser-3516d73d365081c5b29adf7a2b9eff62)
by [Unito](https://www.unito.io)
2026-04-29 08:21:55 -04:00
Terry Jia
57d708767a test(load3d): add unit tests for AnimationManager, CameraManager, RecordingManager, and load3dService (#11733)
## Summary

Add unit tests for the four largest untested logic-heavy modules in the
load3d domain (`AnimationManager`, `CameraManager`, `RecordingManager`,
and the `load3dService` façade).

## Changes

- **What**: 78 new unit tests across 4 files covering animation
lifecycle (mixer setup, clip switching, play/pause/seek, dispose),
camera state (perspective↔orthographic toggling, FOV gating, state
round-trip, controls rebinding, resize math, model fitting), recording
lifecycle (MediaRecorder wiring, indicator visibility, chunk handling,
export/clear paths, dispose ordering), and the service singleton
(sync/async map access, viewer cache, `handleViewerClose` apply-changes
flow, `handleViewportRefresh` camera-toggle dance).

## Review Focus

- **Coverage**: AnimationManager 100%, CameraManager 88.8%,
RecordingManager 89.1%, load3dService 54.5% lines / 88.9% functions. The
service gap is concentrated in one method — `copyLoad3dState` (lines
217-333) — which is entangled with 3-4 subsystems and is intentionally
deferred to a follow-up PR.
- **happy-dom shims** in `RecordingManager.test.ts`: `MediaRecorder`,
`HTMLCanvasElement.prototype.captureStream`, and `getContext('2d')` are
all stubbed because happy-dom doesn't provide them.
`THREE.TextureLoader` is also mocked because the constructor eagerly
loads an SVG indicator.
- **Singleton state reset** in `load3dService.test.ts`: the service
holds a module-level `viewerInstances` Map that can't be reached from
outside. Tests track every node they create in a `Set` and drain via
`removeViewer` in `beforeEach`. Cleaner than `vi.resetModules()` (which
would re-import the service and break the singleton identity).
- **One subtle THREE behavior**: `AnimationManager.setAnimationTime`
clamps to `duration`, but `AnimationMixer.setTime(duration)` with
`LoopRepeat` wraps `action.time` back to 0. The clamping is therefore
only observable through the emitted progress event, not via
`action.time` directly — the test asserts via the event.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11733-test-load3d-add-unit-tests-for-AnimationManager-CameraManager-RecordingManager-and--3516d73d3650812485a0c91065e161f0)
by [Unito](https://www.unito.io)
2026-04-29 08:14:30 -04:00
Benjamin Lu
b3f5f82216 Remove unused queue job components (#11621)
## Summary
Remove the unused legacy queue job row implementation before changing
the live queue popover behavior.

## Changes
- Deleted `JobGroupsList` and its test.
- Deleted `QueueJobItem` and its Storybook story.
- Deleted `QueueAssetPreview`, which was only used by `QueueJobItem`.

## Review Focus
- This PR is deletion-only.
- `rg` found no remaining references to these components.
- `knip` and `typecheck` pass.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11621-Remove-unused-queue-job-components-34d6d73d36508164bf32cb581594cd9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-29 04:55:10 +00:00
Dante
9df4e02189 refactor: unify media asset downloads (#11717)
## Summary

Unifies media asset download actions behind a single
`downloadAssets(assets?)` API to avoid single and multi asset download
path drift.

## Changes

- **What**: Replaces `downloadAsset` and `downloadMultipleAssets` with
`downloadAssets`, preserving no-arg media context fallback and explicit
asset arrays.
- **Dependencies**: None.

## Review Focus

Download behavior for single-card, context-menu, and sidebar bulk
actions should continue to use the same ZIP-export path for cloud
multi-output jobs.

Fixes #11715

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11717-refactor-unify-media-asset-downloads-3506d73d3650810d8bcec9c0194e743d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-29 04:50:45 +00:00
52 changed files with 5271 additions and 1073 deletions

View File

@@ -1,4 +1,4 @@
import type { Mouse } from '@playwright/test'
import type { Locator, Mouse } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
@@ -72,6 +72,22 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.nextFrame()
}
async resizeByDragging(
element: Locator,
{ x, y }: { x?: number; y?: number }
) {
const elementBox = await element.boundingBox()
if (!elementBox) throw new Error('element should have layout')
const cx = elementBox.x + elementBox.width / 2
const cy = elementBox.y + elementBox.height / 2
await this.dragAndDrop(
{ x: cx, y: cy },
{ x: cx + (x ?? 0), y: cy + (y ?? 0) }
)
}
//#region Pass-through
async click(...args: Parameters<Mouse['click']>) {
return await this.mouse.click(...args)

View File

@@ -17,6 +17,9 @@ export class ComfyNodeSearchBoxV2 {
readonly filterChips: Locator
readonly noResults: Locator
readonly nodeIdBadge: Locator
readonly sidebarToggle: Locator
readonly sidebarBackdrop: Locator
readonly filterChipsScroll: Locator
constructor(private comfyPage: ComfyPage) {
const page = comfyPage.page
@@ -28,6 +31,11 @@ export class ComfyNodeSearchBoxV2 {
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
this.sidebarToggle = this.dialog.getByTestId(searchBoxV2.sidebarToggle)
this.sidebarBackdrop = this.dialog.getByTestId(searchBoxV2.sidebarBackdrop)
this.filterChipsScroll = this.dialog.getByTestId(
searchBoxV2.filterChipsScroll
)
}
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */

View File

@@ -7,17 +7,19 @@ import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'
type DragAndDropOptions = {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
}
export class DragDropHelper {
constructor(private readonly page: Page) {}
async dragAndDropExternalResource(
options: {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
} = {}
options: DragAndDropOptions = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
@@ -143,17 +145,14 @@ export class DragDropHelper {
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
options: DragAndDropOptions = {}
): Promise<void> {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropURL(
url: string,
options: {
dropPosition?: Position
preserveNativePropagation?: boolean
} = {}
options: DragAndDropOptions = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}

View File

@@ -210,7 +210,8 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list'
},
errors: {
imageLoadError: 'error-loading-image',
@@ -261,6 +262,9 @@ export const TestIds = {
chipDelete: 'chip-delete',
noResults: 'no-results',
nodeIdBadge: 'node-id-badge',
sidebarToggle: 'toggle-category-sidebar',
sidebarBackdrop: 'sidebar-backdrop',
filterChipsScroll: 'filter-chips-scroll',
category: (id: string) => `category-${id}`,
rootCategory: (id: string) => `search-category-${id}`,
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`

View File

@@ -125,4 +125,151 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
})
})
})
test.describe('Category sidebar', () => {
test('Sidebar toggle hides and shows the category sidebar', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingCategory = searchBoxV2.categoryButton('sampling')
await expect(samplingCategory).toBeVisible()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(samplingCategory).toBeHidden()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await expect(samplingCategory).toBeVisible()
})
test('Filter bar scrolls horizontally while the sidebar toggle stays pinned', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
// Narrow viewport so the chips overflow the filter bar
await comfyPage.page.setViewportSize({ width: 360, height: 800 })
await searchBoxV2.open()
const scrollEl = searchBoxV2.filterChipsScroll
const dims = await scrollEl.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth
}))
expect(dims.scrollWidth).toBeGreaterThan(dims.clientWidth)
await scrollEl.evaluate((el) => {
el.scrollLeft = el.scrollWidth
})
// The toggle lives outside the scroll container, so even when the
// chips scroll hundreds of px it must remain visible in the viewport.
await expect(searchBoxV2.sidebarToggle).toBeInViewport()
})
test('@mobile Sidebar is collapsed by default on mobile', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
})
test('@mobile Clicking outside the sidebar closes it', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeVisible()
await expect(searchBoxV2.sidebarBackdrop).toBeVisible()
// The backdrop spans the full content area, but the sidebar (z-20)
// covers its left ~208px (w-52). Click past that to land on the
// backdrop rather than the sidebar.
await searchBoxV2.sidebarBackdrop.click({ position: { x: 240, y: 40 } })
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
await expect(searchBoxV2.sidebarBackdrop).toBeHidden()
})
test('@mobile Focusing the search input closes the sidebar', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await searchBoxV2.input.focus()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
})
test('Sidebar state across mobile/desktop resizes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const switchToDesktop = () =>
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
const switchToMobile = () =>
comfyPage.page.setViewportSize({ width: 360, height: 800 })
const expectExpanded = (value: 'true' | 'false') =>
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
value
)
await switchToDesktop()
await searchBoxV2.open()
await expectExpanded('true')
await switchToMobile()
await expectExpanded('false')
await searchBoxV2.sidebarToggle.click()
await switchToDesktop()
await expectExpanded('true')
await searchBoxV2.sidebarToggle.click()
await switchToMobile()
await expectExpanded('false')
await switchToDesktop()
await expectExpanded('false')
})
})
})

View File

@@ -0,0 +1,121 @@
import { mergeTests } from '@playwright/test'
import type { Locator, Page, Request } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const TOTAL_MOCK_JOBS = 20
const overflowJobsListRoutePattern = '**/api/jobs?*'
function isHistoryJobsRequest(url: string): boolean {
if (!url.includes('/api/jobs')) return false
const params = new URL(url).searchParams
const statuses = (params.get('status') ?? '').split(',')
return statuses.includes('completed')
}
async function captureNextHistoryRequest(
comfyPage: ComfyPage,
exec: ExecutionHelper
): Promise<Request> {
const requestPromise = comfyPage.page.waitForRequest(
(req) => isHistoryJobsRequest(req.url()),
{ timeout: 5000 }
)
exec.status(0)
return requestPromise
}
function getJobListResults(page: Page): Locator {
return page.getByTestId(TestIds.queue.jobAssetsList).locator('[data-job-id]')
}
test.describe('Queue settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Queue.MaxHistoryItems', () => {
test.describe('limit query parameter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(
createMockJobs(TOTAL_MOCK_JOBS)
)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('limit query parameter on /api/jobs reflects the setting', async ({
comfyPage,
getWebSocket
}) => {
const TARGET_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
TARGET_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
const request = await captureNextHistoryRequest(comfyPage, exec)
const url = new URL(request.url())
expect(url.searchParams.get('limit')).toBe(String(TARGET_LIMIT))
})
})
test('queue panel caps history items to the configured number', async ({
comfyPage,
getWebSocket
}) => {
// Add a mock route that returns all jobs regardless of the request's `limit` param
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
await comfyPage.page.route(
overflowJobsListRoutePattern,
async (route) => {
const url = new URL(route.request().url())
if (!url.searchParams.get('status')?.includes('completed')) {
await route.continue()
return
}
const response = {
jobs: overflowJobs,
pagination: {
offset: 0,
limit: overflowJobs.length,
total: overflowJobs.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
)
const VISIBLE_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
VISIBLE_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
await captureNextHistoryRequest(comfyPage, exec)
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
const jobs = getJobListResults(comfyPage.page)
await expect(jobs.first()).toBeVisible()
await expect(jobs).toHaveCount(VISIBLE_LIMIT)
})
})
})

View File

@@ -364,6 +364,34 @@ test.describe('Workflows sidebar', () => {
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
})
test('Can upload workflow to library by drag and drop', async ({
comfyPage
}) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
const { workflowsTab } = comfyPage.menu
expect(await workflowsTab.getTopLevelSavedWorkflowNames()).not.toContain(
'default'
)
const sidebarBox = (await comfyPage.page
.locator('.workflows-sidebar-tab')
.boundingBox())!
const dropPosition = {
x: sidebarBox.x + sidebarBox.width / 2,
y: sidebarBox.y + sidebarBox.height / 2
}
await comfyPage.dragDrop.dragAndDropFile('default.json', {
dropPosition,
preserveNativePropagation: true
})
await expect
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
.toContain('default')
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({
'workflow1.json': 'default.json'

View File

@@ -0,0 +1,50 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
await test.step('setup', async () => {
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
const getWidth = () =>
comfyPage.page.evaluate(
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
)
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
await expect.poll(() => legacyWidgetRef.getValue()).toBe(-1)
await legacyWidget.click({ position: { x: width - 20, y: height / 2 } })
await expect.poll(() => legacyWidgetRef.getValue()).toBe(0)
})
await test.step('Resize to update width', async () => {
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')
await expect(gutter).toBeVisible()
await comfyMouse.resizeByDragging(gutter, { x: -200 })
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
const intermediateWidth = await getWidth()
await comfyMouse.resizeByDragging(gutter, { x: 100 })
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
})
})

View File

@@ -55,7 +55,9 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically
'tools/devtools/web/**'
],
vite: {
config: ['vite?(.*).config.mts']

View File

@@ -15,7 +15,7 @@ import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { isStaleChunkError, parsePreloadError } from '@/utils/preloadErrorUtil'
import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const workspaceStore = useWorkspaceStore()
@@ -92,14 +92,17 @@ onMounted(() => {
}
})
}
if (isStaleChunkError(info)) {
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
}
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -1,136 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined },
state: { type: String, required: true },
title: { type: String, required: true },
rightText: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconImageUrl: { type: String, default: undefined },
showClear: { type: Boolean, default: undefined },
showMenu: { type: Boolean, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined },
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
template: `
<div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId">
<div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" />
<div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" />
</div>
`
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
const { taskRef, ...rest } = overrides
return {
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: (taskRef ?? {
workflow: { id: 'workflow-id' }
}) as TaskItemImpl,
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...rest
}
}
function getActiveDetailsId(container: Element, jobId: string): string | null {
return (
container
.querySelector(`[data-job-id="${jobId}"]`)
?.getAttribute('data-active-details-id') ?? null
)
}
const renderComponent = (groups: JobGroup[]) =>
render(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
QueueJobItem: QueueJobItemStub
}
}
})
describe('JobGroupsList hover behavior', () => {
afterEach(() => {
vi.useRealTimers()
})
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const { container } = renderComponent([
{ key: 'today', label: 'Today', items: [job] }
])
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-d'))
vi.advanceTimersByTime(199)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-d'))
vi.advanceTimersByTime(149)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
})
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
vi.useFakeTimers()
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
const { container } = renderComponent([
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
])
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-1'))
vi.advanceTimersByTime(200)
await nextTick()
expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-1'))
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-2'))
vi.advanceTimersByTime(100)
await nextTick()
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-2'))
vi.advanceTimersByTime(50)
await nextTick()
expect(getActiveDetailsId(container, 'job-1')).toBeNull()
vi.advanceTimersByTime(50)
await nextTick()
expect(getActiveDetailsId(container, 'job-2')).toBeNull()
})
})

View File

@@ -1,82 +0,0 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-[12px] leading-none text-text-secondary">
{{ group.label }}
</div>
<QueueJobItem
v-for="ji in group.items"
:key="ji.id"
:job-id="ji.id"
:workflow-id="ji.taskRef?.workflowId"
:state="ji.state"
:title="ji.title"
:right-text="ji.meta"
:icon-name="ji.iconName"
:icon-image-url="ji.iconImageUrl"
:show-clear="ji.showClear"
:show-menu="true"
:progress-total-percent="ji.progressTotalPercent"
:progress-current-percent="ji.progressCurrentPercent"
:running-node-name="ji.runningNodeName"
:active-details-id="activeDetailsId"
@cancel="emitCancelItem(ji)"
@delete="emitDeleteItem(ji)"
@menu="(ev) => $emit('menu', ji, ev)"
@view="$emit('viewItem', ji)"
@details-enter="onDetailsEnter"
@details-leave="onDetailsLeave"
/>
</div>
</div>
</template>
<script setup lang="ts">
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const {
activeDetails: activeDetailsId,
clearHoverTimers,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<string>({
getActiveId: (jobId) => jobId,
getDisplayedJobGroups: () => displayedJobGroups
})
function emitCancelItem(item: JobListItem) {
emit('cancelItem', item)
}
function emitDeleteItem(item: JobListItem) {
emit('deleteItem', item)
}
function onDetailsEnter(jobId: string) {
if (activeDetailsId.value === jobId) {
clearHoverTimers()
return
}
scheduleDetailsShow(jobId)
}
function onDetailsLeave(jobId: string) {
scheduleDetailsHide(jobId)
}
</script>

View File

@@ -1,65 +0,0 @@
<template>
<div class="w-[300px] min-w-[260px] rounded-lg shadow-md">
<div class="p-3">
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
<img
ref="imgRef"
:src="imageUrl"
:alt="name"
class="size-full cursor-pointer object-contain"
@click="$emit('image-click')"
@load="onImgLoad"
/>
<div
v-if="timeLabel"
class="absolute bottom-2 left-2 rounded-sm px-2 py-0.5 text-xs text-text-primary"
:style="{
background: 'rgba(217, 217, 217, 0.40)',
backdropFilter: 'blur(2px)'
}"
>
{{ timeLabel }}
</div>
</div>
<div class="mt-2 text-center">
<div
class="truncate text-sm/normal font-semibold text-text-primary"
:title="name"
>
{{ name }}
</div>
<div
v-if="width && height"
class="mt-1 text-xs/normal text-text-secondary"
>
{{ width }}x{{ height }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ inheritAttrs: false })
defineProps<{
imageUrl: string
name: string
timeLabel?: string
}>()
defineEmits(['image-click'])
const imgRef = ref<HTMLImageElement | null>(null)
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const onImgLoad = () => {
const el = imgRef.value
if (!el) return
width.value = el.naturalWidth || null
height.value = el.naturalHeight || null
}
</script>

View File

@@ -1,133 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import QueueJobItem from './QueueJobItem.vue'
const meta: Meta<typeof QueueJobItem> = {
title: 'Queue/QueueJobItem',
component: QueueJobItem,
parameters: {
layout: 'padded'
},
argTypes: {
onCancel: { action: 'cancel' },
onDelete: { action: 'delete' },
onMenu: { action: 'menu' },
onView: { action: 'view' }
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
export const PendingRecentlyAdded: Story = {
args: {
jobId: 'job-pending-added-1',
state: 'pending',
title: 'Job added to queue',
rightText: '12:30 PM',
iconName: 'icon-[lucide--check]'
}
}
export const Pending: Story = {
args: {
jobId: 'job-pending-1',
state: 'pending',
title: 'Pending job',
rightText: '12:31 PM'
}
}
export const Initialization: Story = {
args: {
jobId: 'job-init-1',
state: 'initialization',
title: 'Initializing...'
}
}
export const RunningTotalOnly: Story = {
args: {
jobId: 'job-running-1',
state: 'running',
title: 'Generating image',
progressTotalPercent: 42
}
}
export const RunningWithCurrent: Story = {
args: {
jobId: 'job-running-2',
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
progressCurrentPercent: 10
}
}
export const CompletedWithPreview: Story = {
args: {
jobId: 'job-completed-1',
state: 'completed',
title: 'Prompt #1234',
rightText: '12.79s',
iconImageUrl: thumb('4dabf7')
}
}
export const CompletedNoPreview: Story = {
args: {
jobId: 'job-completed-2',
state: 'completed',
title: 'Prompt #5678',
rightText: '8.12s'
}
}
export const Failed: Story = {
args: {
jobId: 'job-failed-1',
state: 'failed',
title: 'Failed job',
rightText: 'Failed'
}
}
export const Gallery: Story = {
render: (args) => ({
components: { QueueJobItem },
setup() {
return { args }
},
template: `
<div class="flex flex-col gap-2 w-[420px]">
<QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" />
<QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" />
<QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" />
<QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" />
<QueueJobItem
job-id="job-running-2"
state="running"
title="Generating image"
:progress-total-percent="66"
:progress-current-percent="10"
running-node-name="KSampler"
v-bind="args"
/>
<QueueJobItem
job-id="job-completed-1"
state="completed"
title="Prompt #1234"
right-text="12.79s"
icon-image-url="${thumb('4dabf7')}"
v-bind="args"
/>
<QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" />
<QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" />
</div>
`
})
}

View File

@@ -1,362 +0,0 @@
<template>
<div
ref="rowRef"
class="relative"
@mouseenter="onRowEnter"
@mouseleave="onRowLeave"
@contextmenu.stop.prevent="onContextMenu"
>
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
</div>
</Teleport>
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
}"
@mouseenter="onPreviewEnter"
@mouseleave="onPreviewLeave"
>
<QueueAssetPreview
:image-url="iconImageUrl!"
:name="title"
:time-label="rightText || undefined"
@image-click="emit('view')"
/>
</div>
</Teleport>
<div
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div
v-if="
state === 'running' &&
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
"
:class="progressBarContainerClass"
>
<div
v-if="hasProgressPercent(progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(progressTotalPercent)"
/>
<div
v-if="hasProgressPercent(progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(progressCurrentPercent)"
/>
</div>
<div class="relative z-1 flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
<div
class="absolute top-1/2 left-1/2 size-10 -translate-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
/>
<div
class="inline-flex size-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="size-full object-cover"
/>
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
/>
</div>
</div>
</div>
<div class="relative z-1 min-w-0 flex-1">
<div class="truncate opacity-90" :title="title">
<slot name="primary">{{ title }}</slot>
</div>
</div>
<!--
TODO: Refactor action buttons to use a declarative config system.
Instead of hardcoding button visibility logic in the template, define an array of
action button configs with properties like:
- icon, label, action, tooltip
- visibleStates: JobState[] (which job states show this button)
- alwaysVisible: boolean (show without hover)
- destructive: boolean (use destructive styling)
Then render buttons in two groups:
1. Always-visible buttons (outside Transition)
2. Hover-only buttons (inside Transition)
This would eliminate the current duplication where the cancel button exists
both outside (for running) and inside (for pending) the Transition.
-->
<div class="relative z-1 flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
enter-from-class="opacity-0 translate-y-0.5"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-0.5"
>
<div
v-if="isHovered"
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<Button
v-if="state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="
state !== 'completed' &&
state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emit('view')"
>{{ t('menuLabels.View') }}</Button
>
<Button
v-if="showMenu !== undefined ? showMenu : true"
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon-sm"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</div>
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
<slot name="secondary">{{ rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<Button
v-if="state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@comfyorg/tailwind-utils'
const {
jobId,
workflowId,
state,
title,
rightText = '',
iconName,
iconImageUrl,
showClear,
showMenu,
progressTotalPercent,
progressCurrentPercent,
activeDetailsId = null
} = defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
activeDetailsId?: string | null
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'delete'): void
(e: 'menu', event: MouseEvent): void
(e: 'view'): void
(e: 'details-enter', jobId: string): void
(e: 'details-leave', jobId: string): void
}>()
const { t } = useI18n()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => activeDetailsId === jobId)
const onRowEnter = () => {
if (!isPreviewVisible.value) emit('details-enter', jobId)
}
const onRowLeave = () => emit('details-leave', jobId)
const onPopoverEnter = () => emit('details-enter', jobId)
const onPopoverLeave = () => emit('details-leave', jobId)
const isPreviewVisible = ref(false)
const previewHideTimer = ref<number | null>(null)
const previewShowTimer = ref<number | null>(null)
const clearPreviewHideTimer = () => {
if (previewHideTimer.value !== null) {
clearTimeout(previewHideTimer.value)
previewHideTimer.value = null
}
}
const clearPreviewShowTimer = () => {
if (previewShowTimer.value !== null) {
clearTimeout(previewShowTimer.value)
previewShowTimer.value = null
}
}
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
const scheduleShowPreview = () => {
if (!canShowPreview.value) return
clearPreviewHideTimer()
clearPreviewShowTimer()
previewShowTimer.value = window.setTimeout(() => {
isPreviewVisible.value = true
previewShowTimer.value = null
}, 200)
}
const scheduleHidePreview = () => {
clearPreviewHideTimer()
clearPreviewShowTimer()
previewHideTimer.value = window.setTimeout(() => {
isPreviewVisible.value = false
previewHideTimer.value = null
}, 150)
}
const onIconEnter = () => scheduleShowPreview()
const onIconLeave = () => scheduleHidePreview()
const onPreviewEnter = () => scheduleShowPreview()
const onPreviewLeave = () => scheduleHidePreview()
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const updatePopoverPosition = () => {
const el = rowRef.value
if (!el) return
const rect = el.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
}
const isAnyPopoverVisible = computed(
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
)
watch(
isAnyPopoverVisible,
(visible) => {
if (visible) {
nextTick(updatePopoverPosition)
} else {
popoverPosition.value = null
}
},
{ immediate: false }
)
const isHovered = ref(false)
const iconClass = computed(() => {
if (iconName) return iconName
return iconForJobState(state)
})
const shouldSpin = computed(
() =>
state === 'pending' &&
iconClass.value === iconForJobState('pending') &&
!iconImageUrl
)
const computedShowClear = computed(() => {
if (showClear !== undefined) return showClear
return state !== 'completed'
})
const emitDetailsLeave = () => emit('details-leave', jobId)
const onCancelClick = () => {
emitDetailsLeave()
emit('cancel')
}
const onDeleteClick = () => {
emitDetailsLeave()
emit('delete')
}
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = showMenu !== undefined ? showMenu : true
if (shouldShowMenu) emit('menu', event)
}
</script>

View File

@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
setViewport,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -15,10 +17,14 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
const DESKTOP_VIEWPORT = { width: 1280, height: 800 }
const MOBILE_VIEWPORT = { width: 360, height: 800 }
describe('NodeSearchContent', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
setViewport(DESKTOP_VIEWPORT)
const settings = useSettingStore()
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
@@ -547,7 +553,7 @@ describe('NodeSearchContent', () => {
})
describe('filter integration', () => {
it('should display active filters in the input area', () => {
it('renders one chip per active filter with the filter value', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
@@ -556,16 +562,20 @@ describe('NodeSearchContent', () => {
})
])
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
renderComponent({
filters: [
{
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
value: 'IMAGE'
}
{ filterDef: inputFilter, value: 'IMAGE' },
{ filterDef: inputFilter, value: 'LATENT' }
]
})
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
const chipTexts = screen
.getAllByTestId('filter-chip')
.map((c) => c.textContent ?? '')
expect(chipTexts).toHaveLength(2)
expect(chipTexts.some((t) => t.includes('IMAGE'))).toBe(true)
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
})
})
@@ -659,6 +669,95 @@ describe('NodeSearchContent', () => {
})
})
describe('sidebar toggle', () => {
it('should hide and show the category sidebar when the toggle is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
})
])
const { user } = renderComponent()
const sidebar = await screen.findByTestId('category-sampling')
expect(sidebar).toBeVisible()
const toggle = screen.getByTestId('toggle-category-sidebar')
expect(toggle).toHaveAttribute('aria-expanded', 'true')
await user.click(toggle)
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByTestId('category-sampling')).not.toBeVisible()
})
await user.click(toggle)
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('category-sampling')).toBeVisible()
})
})
it('should close the sidebar when the search input gains focus on mobile', async () => {
setViewport(MOBILE_VIEWPORT)
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
})
])
const { user } = renderComponent()
const toggle = screen.getByTestId('toggle-category-sidebar')
expect(toggle).toHaveAttribute('aria-expanded', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-expanded', 'false')
})
})
it('should preserve user state across mobile/desktop resizes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
})
])
const { user } = renderComponent()
const toggle = screen.getByTestId('toggle-category-sidebar')
const expectExpanded = (value: 'true' | 'false') =>
waitFor(() => expect(toggle).toHaveAttribute('aria-expanded', value))
await expectExpanded('true')
setViewport(MOBILE_VIEWPORT)
await expectExpanded('false')
await user.click(toggle)
setViewport(DESKTOP_VIEWPORT)
await expectExpanded('true')
await user.click(toggle)
setViewport(MOBILE_VIEWPORT)
await expectExpanded('false')
setViewport(DESKTOP_VIEWPORT)
await expectExpanded('false')
})
})
describe('rootFilter + category + search combination', () => {
it('should intersect rootFilter, selected category, and search query', async () => {
useNodeDefStore().updateNodeDefs([

View File

@@ -13,11 +13,13 @@
@navigate-down="navigateResults(1)"
@navigate-up="navigateResults(-1)"
@select-current="selectCurrentResult"
@focusin="onSearchFocus"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
v-model:is-sidebar-open="isSidebarOpen"
class="flex-1"
:filters="filters"
:active-category="rootFilter"
@@ -34,11 +36,13 @@
</div>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar -->
<div class="relative flex min-h-0 flex-1 overflow-hidden">
<NodeSearchCategorySidebar
v-show="isSidebarOpen"
id="node-search-category-sidebar"
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
:aria-label="isMobile ? t('g.categories') : undefined"
class="w-52 shrink-0 max-md:absolute max-md:inset-y-0 max-md:left-0 max-md:z-20 max-md:bg-base-background max-md:shadow-interface"
:hide-chevrons="!anyTreeCategoryHasChildren"
:hide-presets="rootFilter !== null"
:node-defs="rootFilteredNodeDefs"
@@ -47,6 +51,14 @@
@auto-expand="selectedCategory = $event"
/>
<!-- Mobile overlay backdrop to close sidebar on outside click -->
<div
v-if="isMobile && isSidebarOpen"
data-testid="sidebar-backdrop"
class="absolute inset-0 z-10 md:hidden"
@click="isSidebarOpen = false"
/>
<!-- Results list -->
<div
id="results-list"
@@ -78,8 +90,8 @@
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="rootFilter !== 'essentials'"
:hide-bookmark-icon="selectedCategory === 'favorites'"
:show-source-badge="rootFilter !== RootCategory.Essentials"
:hide-bookmark-icon="selectedCategory === RootCategory.Favorites"
/>
</div>
<div
@@ -96,6 +108,7 @@
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { FocusScope } from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -106,6 +119,8 @@ import NodeSearchCategorySidebar, {
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -121,9 +136,9 @@ import { cn } from '@comfyorg/tailwind-utils'
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
{
essentials: isEssentialNode,
comfy: (n) => n.nodeSource.type === NodeSourceType.Core,
custom: isCustomNode
[RootCategory.Essentials]: isEssentialNode,
[RootCategory.Comfy]: (n) => n.nodeSource.type === NodeSourceType.Core,
[RootCategory.Custom]: isCustomNode
}
const { filters } = defineProps<{
@@ -167,22 +182,33 @@ const searchQuery = ref('')
const selectedCategory = ref(DEFAULT_CATEGORY)
const selectedIndex = ref(0)
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
const isSidebarOpen = ref(!isMobile.value)
watch(isMobile, (mobile) => {
// On transitioning to mobile state, close the sidebar
if (mobile) isSidebarOpen.value = false
})
function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<string | null>(null)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {
case 'favorites':
case RootCategory.Favorites:
return t('g.bookmarked')
case BLUEPRINT_CATEGORY:
case RootCategory.Blueprint:
return t('g.blueprints')
case 'partner-nodes':
case RootCategory.PartnerNodes:
return t('g.partner')
case 'essentials':
case RootCategory.Essentials:
return t('g.essentials')
case 'comfy':
case RootCategory.Comfy:
return t('g.comfy')
case 'custom':
case RootCategory.Custom:
return t('g.extensions')
default:
return undefined
@@ -195,11 +221,11 @@ const rootFilteredNodeDefs = computed(() => {
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case 'favorites':
case RootCategory.Favorites:
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case BLUEPRINT_CATEGORY:
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
case 'partner-nodes':
case RootCategory.Blueprint:
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
case RootCategory.PartnerNodes:
return allNodes.filter((n) => n.api_node)
default:
return allNodes
@@ -226,7 +252,7 @@ function onClearFilterGroup(filterId: string) {
}
}
function onSelectCategory(category: string) {
function onSelectCategory(category: RootCategoryId) {
if (rootFilter.value === category) {
rootFilter.value = null
} else {

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/vue'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -9,23 +9,16 @@ import {
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
describe(NodeSearchFilterBar, () => {
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
const settings = useSettingStore()
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
@@ -38,8 +31,13 @@ describe(NodeSearchFilterBar, () => {
async function createRender(props = {}) {
const user = userEvent.setup()
const onSelectCategory = vi.fn()
const onUpdateIsSidebarOpen = vi.fn()
render(NodeSearchFilterBar, {
props: { onSelectCategory, ...props },
props: {
onSelectCategory,
'onUpdate:isSidebarOpen': onUpdateIsSidebarOpen,
...props
},
global: {
plugins: [testI18n],
stubs: {
@@ -51,51 +49,38 @@ describe(NodeSearchFilterBar, () => {
}
})
await nextTick()
return { user, onSelectCategory }
return { user, onSelectCategory, onUpdateIsSidebarOpen }
}
it('should render Extensions button and Input/Output popover triggers', async () => {
await createRender({ hasCustomNodes: true })
const buttonTexts = () =>
screen.getAllByRole('button').map((b) => b.textContent?.trim())
const buttons = screen.getAllByRole('button')
const texts = buttons.map((b) => b.textContent?.trim())
expect(texts).toContain('Extensions')
it.each([
{ prop: 'hasFavorites', label: 'Bookmarked' },
{ prop: 'hasBlueprintNodes', label: 'Blueprints' },
{ prop: 'hasEssentialNodes', label: 'Essentials' },
{ prop: 'hasPartnerNodes', label: 'Partner' },
{ prop: 'hasCustomNodes', label: 'Extensions' }
] as const)(
'shows the $label button only when $prop is true',
async ({ prop, label }) => {
await createRender()
expect(buttonTexts()).not.toContain(label)
cleanup()
await createRender({ [prop]: true })
expect(buttonTexts()).toContain(label)
}
)
it('always renders the Comfy button and Input/Output type filter triggers', async () => {
await createRender()
const texts = buttonTexts()
expect(texts).toContain('Comfy')
expect(texts).toContain('Input')
expect(texts).toContain('Output')
})
it('should always render Comfy button', async () => {
await createRender()
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).toContain('Comfy')
})
it('should render conditional category buttons when matching nodes exist', async () => {
await createRender({
hasFavorites: true,
hasEssentialNodes: true,
hasBlueprintNodes: true,
hasPartnerNodes: true
})
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).toContain('Bookmarked')
expect(texts).toContain('Blueprints')
expect(texts).toContain('Partner')
expect(texts).toContain('Essentials')
})
it('should not render Extensions button when no custom nodes exist', async () => {
await createRender()
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).not.toContain('Extensions')
})
it('should emit selectCategory when category button is clicked', async () => {
const { user, onSelectCategory } = await createRender({
hasCustomNodes: true
@@ -114,4 +99,24 @@ describe(NodeSearchFilterBar, () => {
'true'
)
})
it('should expose aria-expanded=false and emit update:isSidebarOpen=true when toggled from collapsed', async () => {
const { user, onUpdateIsSidebarOpen } = await createRender({
isSidebarOpen: false
})
const toggle = screen.getByTestId('toggle-category-sidebar')
expect(toggle).toHaveAttribute('aria-expanded', 'false')
await user.click(toggle)
expect(onUpdateIsSidebarOpen).toHaveBeenCalledExactlyOnceWith(true)
})
it('should expose aria-expanded=true when isSidebarOpen prop is true', async () => {
await createRender({ isSidebarOpen: true })
expect(screen.getByTestId('toggle-category-sidebar')).toHaveAttribute(
'aria-expanded',
'true'
)
})
})

View File

@@ -1,48 +1,67 @@
<template>
<div class="flex items-center gap-2.5 px-3">
<!-- Category filter buttons -->
<div class="flex min-w-0 items-center gap-2.5 pl-3">
<button
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
:data-testid="`search-category-${btn.id}`"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
data-testid="toggle-category-sidebar"
aria-controls="node-search-category-sidebar"
:aria-expanded="isSidebarOpen"
:aria-label="isSidebarOpen ? t('g.hideLeftPanel') : t('g.showLeftPanel')"
:class="chipClass(isSidebarOpen)"
@click="isSidebarOpen = !isSidebarOpen"
>
{{ btn.label }}
<i class="icon-[lucide--panel-left] size-4" />
</button>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
<div
data-testid="filter-chips-scroll"
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto pr-3"
>
<!-- Category filter buttons -->
<button
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
:data-testid="`search-filter-${tf.chip.key}`"
:class="chipClass(false, tf.values.length > 0)"
:data-testid="`search-category-${btn.id}`"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
>
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
{{ btn.label }}
</button>
</NodeSearchTypeFilterPopover>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<button
type="button"
:data-testid="`search-filter-${tf.chip.key}`"
:class="chipClass(false, tf.values.length > 0)"
>
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
</button>
</NodeSearchTypeFilterPopover>
</div>
</div>
</template>
@@ -63,6 +82,7 @@ import { useI18n } from 'vue-i18n'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
@@ -86,11 +106,13 @@ const {
hasCustomNodes?: boolean
}>()
const isSidebarOpen = defineModel<boolean>('isSidebarOpen', { default: true })
const emit = defineEmits<{
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
clearFilterGroup: [filterId: string]
focusSearch: []
selectCategory: [category: string]
selectCategory: [category: RootCategoryId]
}>()
const { t } = useI18n()
@@ -99,20 +121,20 @@ const nodeDefStore = useNodeDefStore()
const MAX_VISIBLE_DOTS = 4
const categoryButtons = computed(() => {
const buttons: { id: string; label: string }[] = []
const buttons: { id: RootCategoryId; label: string }[] = []
if (hasFavorites) {
buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') })
}
if (hasBlueprintNodes) {
buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') })
}
if (hasPartnerNodes) {
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
}
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
if (hasEssentialNodes) {
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
}
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
if (hasPartnerNodes) {
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
}
if (hasCustomNodes) {
buttons.push({ id: RootCategory.Custom, label: t('g.extensions') })
}
@@ -146,7 +168,7 @@ const typeFilters = computed(() => [
function chipClass(isActive: boolean, hasSelections = false) {
return cn(
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
'flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
isActive
? 'border-base-foreground bg-base-foreground text-base-background'
: hasSelections

View File

@@ -57,6 +57,19 @@ describe('NodeSearchListItem', () => {
})
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
})
it('hides id name for subgraph blueprints even when ShowIdName is enabled', () => {
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
true
renderItem({
nodeDef: createMockNodeDef({
name: 'SubgraphBlueprint.e21be61fc452df75e1324e3cc97c41fb0c01a08a5dad4dcd3a2ac118d8907025',
display_name: 'My Blueprint',
python_module: 'blueprint'
})
})
expect(screen.queryByTestId('node-id-badge')).not.toBeInTheDocument()
})
})
describe('showDescription mode', () => {

View File

@@ -155,8 +155,10 @@ const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
const showIdName = computed(
() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') &&
nodeDef.nodeSource.type !== NodeSourceType.Blueprint
)
const showNodeFrequency = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import type { DetachedWindowAPI } from 'happy-dom'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
@@ -35,3 +36,12 @@ export const testI18n = createI18n({
locale: 'en',
messages: { en: enMessages }
})
export function setViewport(viewport: { width: number; height: number }) {
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
.happyDOM
if (!happyDOM) {
throw new Error('window.happyDOM is unavailable to set viewport')
}
happyDOM.setViewport(viewport)
}

View File

@@ -327,7 +327,7 @@ const {
} = useAssetSelection()
const {
downloadMultipleAssets,
downloadAssets,
deleteAssets,
addMultipleToWorkflow,
openMultipleWorkflows,
@@ -533,7 +533,7 @@ function handleContextMenuHide() {
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
downloadAssets(assets)
clearSelection()
}
@@ -559,7 +559,7 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
}
const handleDownloadSelected = () => {
downloadMultipleAssets(selectedAssets.value)
downloadAssets(selectedAssets.value)
clearSelection()
}

View File

@@ -1,9 +1,15 @@
<template>
<SidebarTabTemplate
ref="sidebarTabRef"
:title="title"
v-bind="$attrs"
:data-testid="dataTestid"
class="workflows-sidebar-tab"
:class="
cn(
'workflows-sidebar-tab',
isOverDropZone && 'bg-primary-500/10 ring-4 ring-primary-500 ring-inset'
)
"
>
<template #alt-title>
<slot name="alt-title" />
@@ -140,8 +146,10 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { unrefElement, useDropZone } from '@vueuse/core'
import ConfirmDialog from 'primevue/confirmdialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
@@ -162,6 +170,8 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { getDataFromJSON } from '@/scripts/metadata/json'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import {
@@ -189,6 +199,7 @@ const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const sidebarTabRef = useTemplateRef('sidebarTabRef')
const searchBoxRef = ref()
@@ -349,4 +360,34 @@ onMounted(async () => {
searchBoxRef.value?.focus()
await workflowBookmarkStore.loadBookmarks()
})
const sidebarTabGetter = () => {
const el = unrefElement(sidebarTabRef)
return el instanceof HTMLElement ? el : undefined
}
const { isOverDropZone } = useDropZone(sidebarTabGetter, {
onDrop: async (files) => {
if (!files?.length) return
await Promise.allSettled(
files.map(async (file) => {
const { workflow } = (await getDataFromJSON(file)) ?? {}
if (!workflow) return
const workflowJSON = await validateComfyWorkflow(workflow)
if (!workflowJSON) return
const comfyWorkflow = workflowStore.createNewTemporary(
file.name,
workflowJSON
)
await workflowStore.closeWorkflow(comfyWorkflow)
await comfyWorkflow.save()
})
)
await workflowStore.syncWorkflows()
},
dataTypes: ['application/json'],
multiple: true,
preventDefaultForUnhandled: false
})
</script>

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useExternalLink } from '@/composables/useExternalLink'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
@@ -23,12 +24,22 @@ vi.mock('vue-i18n', async () => {
vi.mock('@/scripts/app', () => {
const mockGraphClear = vi.fn()
const mockDs = {
scale: 1,
element: { width: 800, height: 600 } as Pick<
HTMLCanvasElement,
'width' | 'height'
>,
changeScale: vi.fn()
}
const mockCanvas = {
subgraph: undefined,
selectedItems: new Set(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
selectItems: vi.fn()
selectItems: vi.fn(),
ds: mockDs,
setDirty: vi.fn()
}
return {
@@ -39,6 +50,8 @@ vi.mock('@/scripts/app', () => {
mockGraphClear()
}
}),
openClipspace: vi.fn(),
refreshComboInNodes: vi.fn().mockResolvedValue(undefined),
canvas: mockCanvas,
rootGraph: {
clear: mockGraphClear
@@ -81,8 +94,27 @@ vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
const mockResetView = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({}))
useLitegraphService: vi.fn(() => ({
resetView: mockResetView
}))
}))
const mockTrackHelpResourceClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackHelpResourceClicked: mockTrackHelpResourceClicked
}))
}))
const mockShowAbout = vi.hoisted(() => vi.fn())
const mockShowSettings = vi.hoisted(() => vi.fn())
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettings,
showAbout: mockShowAbout
}))
}))
vi.mock('@/stores/executionStore', () => ({
@@ -482,4 +514,97 @@ describe('useCoreCommands', () => {
})
})
})
describe('Canvas view commands', () => {
const findCmd = (id: string) =>
useCoreCommands().find((cmd) => cmd.id === id)!
it('Comfy.Canvas.ResetView delegates to litegraphService.resetView', async () => {
await findCmd('Comfy.Canvas.ResetView').function()
expect(mockResetView).toHaveBeenCalled()
})
it('Comfy.Canvas.ZoomIn scales the canvas up by 1.1× and marks it dirty', async () => {
app.canvas.ds.scale = 1
await findCmd('Comfy.Canvas.ZoomIn').function()
expect(app.canvas.ds.changeScale).toHaveBeenCalledWith(
1.1,
expect.any(Array)
)
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('Comfy.Canvas.ZoomOut scales the canvas down by 1/1.1× and marks it dirty', async () => {
app.canvas.ds.scale = 1
await findCmd('Comfy.Canvas.ZoomOut').function()
expect(app.canvas.ds.changeScale).toHaveBeenCalledWith(
1 / 1.1,
expect.any(Array)
)
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
})
describe('Workflow lifecycle commands', () => {
const findCmd = (id: string) =>
useCoreCommands().find((cmd) => cmd.id === id)!
it('Comfy.OpenClipspace delegates to app.openClipspace', async () => {
await findCmd('Comfy.OpenClipspace').function()
expect(app.openClipspace).toHaveBeenCalled()
})
it('Comfy.RefreshNodeDefinitions awaits app.refreshComboInNodes', async () => {
await findCmd('Comfy.RefreshNodeDefinitions').function()
expect(app.refreshComboInNodes).toHaveBeenCalled()
})
})
describe('Help commands', () => {
const findCmd = (id: string) =>
useCoreCommands().find((cmd) => cmd.id === id)!
const { staticUrls } = useExternalLink()
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null as unknown as Window)
})
it('Comfy.Help.OpenComfyUIIssues opens the GitHub issues URL and tracks telemetry', async () => {
await findCmd('Comfy.Help.OpenComfyUIIssues').function()
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
expect.objectContaining({
resource_type: 'github',
is_external: true,
source: 'menu'
})
)
expect(openSpy).toHaveBeenCalledWith(staticUrls.githubIssues, '_blank')
})
it('Comfy.Help.OpenComfyOrgDiscord opens the Discord URL and tracks telemetry', async () => {
await findCmd('Comfy.Help.OpenComfyOrgDiscord').function()
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
expect.objectContaining({
resource_type: 'discord'
})
)
expect(openSpy).toHaveBeenCalledWith(staticUrls.discord, '_blank')
})
it('Comfy.Help.AboutComfyUI opens the About dialog', async () => {
await findCmd('Comfy.Help.AboutComfyUI').function()
expect(mockShowAbout).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,324 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AnimationManager } from './AnimationManager'
import type { EventManagerInterface } from './interfaces'
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
} satisfies EventManagerInterface
}
function makeClip(name: string, duration: number): THREE.AnimationClip {
return new THREE.AnimationClip(name, duration, [])
}
function makeAnimatedModel(
clips: THREE.AnimationClip[] = []
): THREE.Object3D & { animations: THREE.AnimationClip[] } {
const obj = new THREE.Object3D() as THREE.Object3D & {
animations: THREE.AnimationClip[]
}
obj.animations = clips
return obj
}
describe('AnimationManager', () => {
let events: ReturnType<typeof makeMockEventManager>
let manager: AnimationManager
beforeEach(() => {
vi.clearAllMocks()
events = makeMockEventManager()
manager = new AnimationManager(events)
})
describe('setupModelAnimations', () => {
it('creates a mixer and selects the first clip when the model has animations', () => {
const clips = [makeClip('walk', 2), makeClip('run', 3)]
const model = makeAnimatedModel(clips)
manager.setupModelAnimations(model, null)
expect(manager.currentAnimation).not.toBeNull()
expect(manager.animationClips).toEqual(clips)
expect(manager.selectedAnimationIndex).toBe(0)
expect(manager.animationActions).toHaveLength(1)
})
it('falls back to originalModel.animations when the model itself has none', () => {
const clips = [makeClip('idle', 1.5)]
const model = makeAnimatedModel([])
const originalModel = { animations: clips } as unknown as THREE.Object3D
manager.setupModelAnimations(model, originalModel)
expect(manager.animationClips).toEqual(clips)
expect(manager.currentAnimation).not.toBeNull()
})
it('emits the localized animation list with default names when clips are unnamed', () => {
const clips = [makeClip('', 1), makeClip('named', 1)]
const model = makeAnimatedModel(clips)
manager.setupModelAnimations(model, null)
expect(events.emitEvent).toHaveBeenCalledWith('animationListChange', [
{ name: 'Animation 1', index: 0 },
{ name: 'named', index: 1 }
])
})
it('emits an empty list and leaves no actions when neither source has animations', () => {
const model = makeAnimatedModel([])
manager.setupModelAnimations(model, null)
expect(manager.animationClips).toEqual([])
expect(manager.animationActions).toEqual([])
expect(events.emitEvent).toHaveBeenLastCalledWith(
'animationListChange',
[]
)
})
it('stops previously running actions before loading a new model', () => {
const firstClips = [makeClip('a', 1)]
manager.setupModelAnimations(makeAnimatedModel(firstClips), null)
const firstAction = manager.animationActions[0]
const stopSpy = vi.spyOn(firstAction, 'stop')
const secondClips = [makeClip('b', 1)]
manager.setupModelAnimations(makeAnimatedModel(secondClips), null)
expect(stopSpy).toHaveBeenCalled()
expect(manager.animationClips).toEqual(secondClips)
})
})
describe('updateSelectedAnimation', () => {
it('warns and does nothing when called before any setup', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
manager.updateSelectedAnimation(0)
expect(warn).toHaveBeenCalled()
expect(manager.animationActions).toEqual([])
warn.mockRestore()
})
it('warns when the index is out of bounds', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
manager.setupModelAnimations(
makeAnimatedModel([makeClip('only', 1)]),
null
)
manager.updateSelectedAnimation(5)
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
it('switches to the requested clip and emits an initial progress event', () => {
const clips = [makeClip('a', 2), makeClip('b', 4)]
manager.setupModelAnimations(makeAnimatedModel(clips), null)
events.emitEvent.mockClear()
manager.updateSelectedAnimation(1)
expect(manager.selectedAnimationIndex).toBe(1)
expect(manager.animationActions).toHaveLength(1)
expect(events.emitEvent).toHaveBeenCalledWith('animationProgressChange', {
progress: 0,
currentTime: 0,
duration: 4
})
})
it('starts the action paused when the manager is not currently playing', () => {
const clips = [makeClip('a', 2)]
manager.setupModelAnimations(makeAnimatedModel(clips), null)
const action = manager.animationActions[0]
expect(action.paused).toBe(true)
})
it('starts the action running when the manager is already playing', () => {
const clips = [makeClip('a', 2), makeClip('b', 2)]
manager.setupModelAnimations(makeAnimatedModel(clips), null)
manager.toggleAnimation(true)
manager.updateSelectedAnimation(1)
expect(manager.animationActions[0].paused).toBe(false)
})
})
describe('toggleAnimation', () => {
it('warns and is a no-op when there is no animation loaded', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
manager.toggleAnimation(true)
expect(warn).toHaveBeenCalled()
expect(manager.isAnimationPlaying).toBe(false)
warn.mockRestore()
})
it('flips the playing state when called without an explicit value', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 1)]), null)
manager.toggleAnimation()
expect(manager.isAnimationPlaying).toBe(true)
manager.toggleAnimation()
expect(manager.isAnimationPlaying).toBe(false)
})
it('resets time to zero when starting from the end of the clip', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 2)]), null)
const action = manager.animationActions[0]
action.time = action.getClip().duration
manager.toggleAnimation(true)
expect(action.time).toBe(0)
expect(action.paused).toBe(false)
})
})
describe('setAnimationSpeed', () => {
it('records the speed and propagates it to all current actions', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 1)]), null)
const action = manager.animationActions[0]
const setEffectiveTimeScale = vi.spyOn(action, 'setEffectiveTimeScale')
manager.setAnimationSpeed(2.5)
expect(manager.animationSpeed).toBe(2.5)
expect(setEffectiveTimeScale).toHaveBeenCalledWith(2.5)
})
})
describe('setAnimationTime', () => {
it('clamps the requested time to [0, duration]', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
events.emitEvent.mockClear()
manager.setAnimationTime(-5)
expect(events.emitEvent).toHaveBeenLastCalledWith(
'animationProgressChange',
{ progress: 0, currentTime: 0, duration: 4 }
)
manager.setAnimationTime(99)
expect(events.emitEvent).toHaveBeenLastCalledWith(
'animationProgressChange',
{ progress: 100, currentTime: 4, duration: 4 }
)
})
it('preserves the paused state across the seek', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
const action = manager.animationActions[0]
action.paused = true
manager.setAnimationTime(2)
expect(action.paused).toBe(true)
expect(action.time).toBe(2)
})
it('emits a progress event reflecting the seek target', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
events.emitEvent.mockClear()
manager.setAnimationTime(1)
expect(events.emitEvent).toHaveBeenCalledWith('animationProgressChange', {
progress: 25,
currentTime: 1,
duration: 4
})
})
it('is a no-op when no actions are loaded', () => {
expect(() => manager.setAnimationTime(1)).not.toThrow()
expect(events.emitEvent).not.toHaveBeenCalledWith(
'animationProgressChange',
expect.anything()
)
})
})
describe('update', () => {
it('does not advance the mixer when not playing', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
const updateSpy = vi.spyOn(manager.currentAnimation!, 'update')
manager.update(0.5)
expect(updateSpy).not.toHaveBeenCalled()
})
it('advances the mixer and emits progress while playing', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
manager.toggleAnimation(true)
const updateSpy = vi.spyOn(manager.currentAnimation!, 'update')
events.emitEvent.mockClear()
manager.update(0.25)
expect(updateSpy).toHaveBeenCalledWith(0.25)
expect(events.emitEvent).toHaveBeenCalledWith(
'animationProgressChange',
expect.objectContaining({ duration: 4 })
)
})
})
describe('getters', () => {
it('return zero when nothing is loaded', () => {
expect(manager.getAnimationTime()).toBe(0)
expect(manager.getAnimationDuration()).toBe(0)
})
it('reflect the current action time and clip duration', () => {
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 7)]), null)
expect(manager.getAnimationDuration()).toBe(7)
manager.animationActions[0].time = 3
expect(manager.getAnimationTime()).toBe(3)
})
})
describe('dispose', () => {
it('stops all actions, clears state, and emits an empty list', () => {
manager.setupModelAnimations(
makeAnimatedModel([makeClip('a', 1), makeClip('b', 1)]),
null
)
manager.toggleAnimation(true)
manager.setAnimationSpeed(2)
manager.selectedAnimationIndex = 1
const stopSpies = manager.animationActions.map((action) =>
vi.spyOn(action, 'stop')
)
events.emitEvent.mockClear()
manager.dispose()
stopSpies.forEach((spy) => expect(spy).toHaveBeenCalled())
expect(manager.currentAnimation).toBeNull()
expect(manager.animationActions).toEqual([])
expect(manager.animationClips).toEqual([])
expect(manager.selectedAnimationIndex).toBe(0)
expect(manager.isAnimationPlaying).toBe(false)
expect(manager.animationSpeed).toBe(1.0)
expect(events.emitEvent).toHaveBeenCalledWith('animationListChange', [])
})
})
})

View File

@@ -0,0 +1,233 @@
import * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CameraManager } from './CameraManager'
import type { CameraState, EventManagerInterface } from './interfaces'
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
} satisfies EventManagerInterface
}
type ControlsListener = () => void
function makeControlsStub() {
const listeners: Record<string, ControlsListener[]> = {}
return {
target: new THREE.Vector3(),
object: null as THREE.Camera | null,
update: vi.fn(),
addEventListener: vi.fn((event: string, cb: ControlsListener) => {
listeners[event] = listeners[event] ?? []
listeners[event].push(cb)
}),
fire(event: string) {
listeners[event]?.forEach((cb) => cb())
}
}
}
function makeRenderer(): THREE.WebGLRenderer {
// CameraManager only stores `_renderer` but never reads it. An empty object
// suffices and avoids needing a WebGL context in happy-dom.
return {} as THREE.WebGLRenderer
}
describe('CameraManager', () => {
let events: ReturnType<typeof makeMockEventManager>
let manager: CameraManager
beforeEach(() => {
vi.clearAllMocks()
events = makeMockEventManager()
manager = new CameraManager(makeRenderer(), events)
})
describe('construction', () => {
it('creates both cameras and starts in perspective mode at the default position', () => {
expect(manager.perspectiveCamera).toBeInstanceOf(THREE.PerspectiveCamera)
expect(manager.orthographicCamera).toBeInstanceOf(
THREE.OrthographicCamera
)
expect(manager.activeCamera).toBe(manager.perspectiveCamera)
expect(manager.getCurrentCameraType()).toBe('perspective')
expect(manager.perspectiveCamera.position.toArray()).toEqual([10, 10, 10])
})
})
describe('toggleCamera', () => {
it('without an argument flips between perspective and orthographic', () => {
manager.toggleCamera()
expect(manager.getCurrentCameraType()).toBe('orthographic')
manager.toggleCamera()
expect(manager.getCurrentCameraType()).toBe('perspective')
})
it('with an explicit type switches to that type', () => {
manager.toggleCamera('orthographic')
expect(manager.activeCamera).toBe(manager.orthographicCamera)
})
it('is a no-op when explicitly switched to the active type', () => {
const before = manager.activeCamera
manager.toggleCamera('perspective')
expect(manager.activeCamera).toBe(before)
expect(events.emitEvent).not.toHaveBeenCalledWith(
'cameraTypeChange',
'perspective'
)
})
it('copies position, rotation, and zoom from the old camera to the new one', () => {
manager.perspectiveCamera.position.set(1, 2, 3)
manager.perspectiveCamera.rotation.set(0.1, 0.2, 0.3)
manager.perspectiveCamera.zoom = 1.5
manager.toggleCamera('orthographic')
expect(manager.orthographicCamera.position.toArray()).toEqual([1, 2, 3])
expect(manager.orthographicCamera.zoom).toBe(1.5)
})
it('emits cameraTypeChange with the requested type', () => {
manager.toggleCamera('orthographic')
expect(events.emitEvent).toHaveBeenCalledWith(
'cameraTypeChange',
'orthographic'
)
})
it('rebinds the controls object and target after switching', () => {
const controls = makeControlsStub()
controls.target.set(5, 6, 7)
manager.setControls(controls as unknown as OrbitControls)
manager.toggleCamera('orthographic')
expect(controls.object).toBe(manager.orthographicCamera)
expect(controls.target.toArray()).toEqual([5, 6, 7])
expect(controls.update).toHaveBeenCalled()
})
})
describe('setFOV', () => {
it('updates the perspective FOV when perspective is active and emits the value', () => {
manager.setFOV(60)
expect(manager.perspectiveCamera.fov).toBe(60)
expect(events.emitEvent).toHaveBeenCalledWith('fovChange', 60)
})
it('does not modify the perspective FOV when orthographic is active', () => {
manager.toggleCamera('orthographic')
events.emitEvent.mockClear()
const before = manager.perspectiveCamera.fov
manager.setFOV(99)
expect(manager.perspectiveCamera.fov).toBe(before)
expect(events.emitEvent).toHaveBeenCalledWith('fovChange', 99)
})
})
describe('camera state round-trip', () => {
it('captures and restores position, target, zoom, and type', () => {
const controls = makeControlsStub()
controls.target.set(2, 3, 4)
manager.setControls(controls as unknown as OrbitControls)
manager.perspectiveCamera.position.set(7, 8, 9)
manager.perspectiveCamera.zoom = 2
const snapshot = manager.getCameraState()
expect(snapshot.position.toArray()).toEqual([7, 8, 9])
expect(snapshot.target.toArray()).toEqual([2, 3, 4])
expect(snapshot.zoom).toBe(2)
expect(snapshot.cameraType).toBe('perspective')
manager.perspectiveCamera.position.set(0, 0, 0)
manager.perspectiveCamera.zoom = 1
manager.setCameraState(snapshot)
expect(manager.perspectiveCamera.position.toArray()).toEqual([7, 8, 9])
expect(manager.perspectiveCamera.zoom).toBe(2)
expect(controls.target.toArray()).toEqual([2, 3, 4])
})
it('returns a default target when no controls are attached', () => {
const snapshot = manager.getCameraState()
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
})
})
describe('setControls', () => {
it('emits cameraChanged when the controls fire their end event', () => {
const controls = makeControlsStub()
manager.setControls(controls as unknown as OrbitControls)
events.emitEvent.mockClear()
controls.fire('end')
expect(events.emitEvent).toHaveBeenCalledWith(
'cameraChanged',
expect.objectContaining({
cameraType: 'perspective'
}) satisfies Partial<CameraState>
)
})
})
describe('handleResize', () => {
it('updates perspective aspect when perspective is active', () => {
manager.handleResize(800, 400)
expect(manager.perspectiveCamera.aspect).toBeCloseTo(2)
})
it('updates orthographic frustum bounds when orthographic is active', () => {
manager.toggleCamera('orthographic')
manager.handleResize(800, 400)
const cam = manager.orthographicCamera
const aspect = 2
const frustumSize = 10
expect(cam.left).toBeCloseTo((-frustumSize * aspect) / 2)
expect(cam.right).toBeCloseTo((frustumSize * aspect) / 2)
expect(cam.top).toBeCloseTo(frustumSize / 2)
expect(cam.bottom).toBeCloseTo(-frustumSize / 2)
})
})
describe('setupForModel', () => {
it('positions both cameras based on the model size and centers controls on the target', () => {
const controls = makeControlsStub()
manager.setControls(controls as unknown as OrbitControls)
const size = new THREE.Vector3(2, 4, 2)
manager.setupForModel(size)
expect(manager.perspectiveCamera.position.toArray()).toEqual([4, 6, 4])
expect(manager.orthographicCamera.position.toArray()).toEqual([4, 6, 4])
expect(controls.target.toArray()).toEqual([0, 2, 0])
expect(controls.update).toHaveBeenCalled()
})
})
describe('reset', () => {
it('returns both cameras to the default starting position', () => {
manager.perspectiveCamera.position.set(99, 99, 99)
manager.orthographicCamera.position.set(99, 99, 99)
manager.reset()
expect(manager.perspectiveCamera.position.toArray()).toEqual([10, 10, 10])
expect(manager.orthographicCamera.position.toArray()).toEqual([
10, 10, 10
])
})
})
})

View File

@@ -0,0 +1,168 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ControlsManager } from './ControlsManager'
import type { EventManagerInterface } from './interfaces'
const { mockOrbitControls } = vi.hoisted(() => ({
mockOrbitControls: vi.fn()
}))
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
type Listener = () => void
class OrbitControls {
object: THREE.Camera
domElement: HTMLElement
enableDamping = false
target = new THREE.Vector3()
update = vi.fn()
dispose = vi.fn()
private listeners = new Map<string, Listener[]>()
constructor(camera: THREE.Camera, domElement: HTMLElement) {
this.object = camera
this.domElement = domElement
mockOrbitControls(camera, domElement)
}
addEventListener(event: string, cb: Listener) {
if (!this.listeners.has(event)) this.listeners.set(event, [])
this.listeners.get(event)!.push(cb)
}
fire(event: string) {
this.listeners.get(event)?.forEach((cb) => cb())
}
}
return { OrbitControls }
})
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
} satisfies EventManagerInterface
}
function makeRenderer(opts: { withParent?: boolean } = {}) {
const canvas = document.createElement('canvas')
if (opts.withParent) {
const parent = document.createElement('div')
parent.appendChild(canvas)
}
return { domElement: canvas } as unknown as THREE.WebGLRenderer
}
describe('ControlsManager', () => {
let events: ReturnType<typeof makeMockEventManager>
let camera: THREE.PerspectiveCamera
let manager: ControlsManager
beforeEach(() => {
vi.clearAllMocks()
events = makeMockEventManager()
camera = new THREE.PerspectiveCamera()
})
describe('construction', () => {
it('attaches OrbitControls to the canvas parent when one exists', () => {
const renderer = makeRenderer({ withParent: true })
manager = new ControlsManager(renderer, camera, events)
expect(mockOrbitControls).toHaveBeenCalledWith(
camera,
renderer.domElement.parentElement
)
expect(manager.controls.enableDamping).toBe(true)
})
it('falls back to the canvas itself when there is no parent', () => {
const renderer = makeRenderer({ withParent: false })
manager = new ControlsManager(renderer, camera, events)
expect(mockOrbitControls).toHaveBeenCalledWith(
camera,
renderer.domElement
)
})
})
describe('init', () => {
it('emits cameraChanged with a perspective state when the controls fire end', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
camera.position.set(1, 2, 3)
camera.zoom = 1.25
manager.controls.target.set(4, 5, 6)
manager.init()
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
expect(events.emitEvent).toHaveBeenCalledWith('cameraChanged', {
position: expect.objectContaining({ x: 1, y: 2, z: 3 }),
target: expect.objectContaining({ x: 4, y: 5, z: 6 }),
zoom: 1.25,
cameraType: 'perspective'
})
})
it('reports orthographic camera type when initialized with one', () => {
const ortho = new THREE.OrthographicCamera()
ortho.zoom = 0.5
manager = new ControlsManager(makeRenderer(), ortho, events)
manager.init()
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
expect(events.emitEvent).toHaveBeenCalledWith(
'cameraChanged',
expect.objectContaining({ cameraType: 'orthographic', zoom: 0.5 })
)
})
})
describe('updateCamera', () => {
it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
camera.position.set(7, 8, 9)
manager.controls.target.set(1, 1, 1)
const newCamera = new THREE.PerspectiveCamera()
manager.updateCamera(newCamera)
expect(manager.controls.object).toBe(newCamera)
expect(newCamera.position.toArray()).toEqual([7, 8, 9])
expect(manager.controls.target.toArray()).toEqual([1, 1, 1])
expect(manager.controls.update).toHaveBeenCalled()
})
})
describe('update / reset', () => {
it('update delegates to controls.update', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager.update()
expect(manager.controls.update).toHaveBeenCalled()
})
it('reset clears the target back to the origin and refreshes', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager.controls.target.set(5, 6, 7)
manager.reset()
expect(manager.controls.target.toArray()).toEqual([0, 0, 0])
expect(manager.controls.update).toHaveBeenCalled()
})
})
describe('dispose', () => {
it('disposes the underlying OrbitControls', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager.dispose()
expect(manager.controls.dispose).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventManager } from './EventManager'
describe('EventManager', () => {
let manager: EventManager
beforeEach(() => {
vi.clearAllMocks()
manager = new EventManager()
})
describe('emitEvent', () => {
it('does nothing when there are no listeners for the event', () => {
expect(() => manager.emitEvent('unknown', { x: 1 })).not.toThrow()
})
it('invokes every listener registered for the event with the payload', () => {
const a = vi.fn()
const b = vi.fn()
manager.addEventListener('change', a)
manager.addEventListener('change', b)
manager.emitEvent('change', { value: 7 })
expect(a).toHaveBeenCalledWith({ value: 7 })
expect(b).toHaveBeenCalledWith({ value: 7 })
})
it('does not invoke listeners registered for a different event', () => {
const cb = vi.fn()
manager.addEventListener('a', cb)
manager.emitEvent('b', null)
expect(cb).not.toHaveBeenCalled()
})
})
describe('removeEventListener', () => {
it('detaches a previously added listener', () => {
const cb = vi.fn()
manager.addEventListener('change', cb)
manager.removeEventListener('change', cb)
manager.emitEvent('change', null)
expect(cb).not.toHaveBeenCalled()
})
it('leaves other listeners on the same event intact', () => {
const a = vi.fn()
const b = vi.fn()
manager.addEventListener('change', a)
manager.addEventListener('change', b)
manager.removeEventListener('change', a)
manager.emitEvent('change', null)
expect(a).not.toHaveBeenCalled()
expect(b).toHaveBeenCalled()
})
it('is safely a no-op for an event that has never been listened to', () => {
expect(() => manager.removeEventListener('never', vi.fn())).not.toThrow()
})
})
})

View File

@@ -0,0 +1,150 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import { LightingManager } from './LightingManager'
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
} satisfies EventManagerInterface
}
describe('LightingManager', () => {
let scene: THREE.Scene
let events: ReturnType<typeof makeMockEventManager>
let manager: LightingManager
beforeEach(() => {
vi.clearAllMocks()
scene = new THREE.Scene()
events = makeMockEventManager()
manager = new LightingManager(scene, events)
})
describe('init / setupLights', () => {
it('adds six lights — one ambient and five directionals — to the scene', () => {
manager.init()
expect(manager.lights).toHaveLength(6)
const ambient = manager.lights.filter(
(l) => l instanceof THREE.AmbientLight
)
const directional = manager.lights.filter(
(l) => l instanceof THREE.DirectionalLight
)
expect(ambient).toHaveLength(1)
expect(directional).toHaveLength(5)
manager.lights.forEach((light) => {
expect(scene.children).toContain(light)
})
})
it('positions the directional lights to surround the model', () => {
manager.init()
const positions = manager.lights
.filter(
(l): l is THREE.DirectionalLight =>
l instanceof THREE.DirectionalLight
)
.map((l) => l.position.toArray())
expect(positions).toEqual(
expect.arrayContaining([
[0, 10, 10],
[0, 10, -10],
[-10, 0, 0],
[10, 0, 0],
[0, -10, 0]
])
)
})
})
describe('setLightIntensity', () => {
it('scales each light by its stored multiplier and records the requested intensity', () => {
manager.init()
const ambient = manager.lights.find(
(l): l is THREE.AmbientLight => l instanceof THREE.AmbientLight
)!
const mainLight = manager.lights.find(
(l): l is THREE.DirectionalLight =>
l instanceof THREE.DirectionalLight &&
l.position.y === 10 &&
l.position.z === 10
)!
manager.setLightIntensity(2)
expect(manager.currentIntensity).toBe(2)
expect(ambient.intensity).toBeCloseTo(2 * 0.5)
expect(mainLight.intensity).toBeCloseTo(2 * 0.8)
})
it('emits lightIntensityChange with the new intensity', () => {
manager.init()
manager.setLightIntensity(1.5)
expect(events.emitEvent).toHaveBeenCalledWith('lightIntensityChange', 1.5)
})
it('is a no-op (no error) when called before init', () => {
expect(() => manager.setLightIntensity(1)).not.toThrow()
expect(events.emitEvent).toHaveBeenCalledWith('lightIntensityChange', 1)
})
})
describe('setHDRIMode', () => {
it('hides every light when HDRI is active', () => {
manager.init()
manager.setHDRIMode(true)
manager.lights.forEach((light) => {
expect(light.visible).toBe(false)
})
})
it('restores visibility when HDRI is turned off', () => {
manager.init()
manager.setHDRIMode(true)
manager.setHDRIMode(false)
manager.lights.forEach((light) => {
expect(light.visible).toBe(true)
})
})
})
describe('dispose', () => {
it('removes every light from the scene and clears internal state', () => {
manager.init()
const lightCount = manager.lights.length
manager.dispose()
expect(manager.lights).toEqual([])
expect(
scene.children.filter((c) => c instanceof THREE.Light)
).toHaveLength(0)
expect(lightCount).toBeGreaterThan(0)
})
it('resets multipliers so subsequent setLightIntensity calls are no-ops', () => {
manager.init()
manager.dispose()
manager.setLightIntensity(5)
expect(events.emitEvent).toHaveBeenLastCalledWith(
'lightIntensityChange',
5
)
})
})
})

View File

@@ -0,0 +1,303 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelExporter } from './ModelExporter'
const {
downloadBlobMock,
addAlertMock,
gltfParseMock,
objParseMock,
stlParseMock
} = vi.hoisted(() => ({
downloadBlobMock: vi.fn(),
addAlertMock: vi.fn(),
gltfParseMock: vi.fn(),
objParseMock: vi.fn(),
stlParseMock: vi.fn()
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadBlob: downloadBlobMock
}))
vi.mock('@/i18n', () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: addAlertMock })
}))
vi.mock('three/examples/jsm/exporters/GLTFExporter', () => ({
GLTFExporter: class {
parse = gltfParseMock
}
}))
vi.mock('three/examples/jsm/exporters/OBJExporter', () => ({
OBJExporter: class {
parse = objParseMock
}
}))
vi.mock('three/examples/jsm/exporters/STLExporter', () => ({
STLExporter: class {
parse = stlParseMock
}
}))
describe('ModelExporter', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('detectFormatFromURL', () => {
it('extracts the lowercase extension from the filename query parameter', () => {
expect(
ModelExporter.detectFormatFromURL(
'http://example.com/api/view?filename=model.GLB'
)
).toBe('glb')
})
it('returns null when there is no filename parameter', () => {
expect(
ModelExporter.detectFormatFromURL('http://example.com/api/view?foo=bar')
).toBeNull()
})
it('returns null when there is no query string at all', () => {
expect(
ModelExporter.detectFormatFromURL('http://example.com/file.glb')
).toBeNull()
})
it('returns the whole basename when the filename has no dotted extension', () => {
// split('.').pop() returns the only segment when no dot is present.
expect(
ModelExporter.detectFormatFromURL(
'http://example.com/api/view?filename=cube'
)
).toBe('cube')
})
})
describe('canUseDirectURL', () => {
it('returns false for a null URL', () => {
expect(ModelExporter.canUseDirectURL(null, 'glb')).toBe(false)
})
it('returns true when the URL extension matches the requested format (case-insensitive)', () => {
expect(
ModelExporter.canUseDirectURL(
'http://example.com/api/view?filename=cube.GLB',
'glb'
)
).toBe(true)
})
it('returns false when the URL extension does not match', () => {
expect(
ModelExporter.canUseDirectURL(
'http://example.com/api/view?filename=cube.obj',
'glb'
)
).toBe(false)
})
it('returns false when the URL has no detectable format', () => {
expect(
ModelExporter.canUseDirectURL('http://example.com/file.glb', 'glb')
).toBe(false)
})
})
describe('downloadFromURL', () => {
it('fetches the URL and downloads the resulting blob', async () => {
const blob = new Blob(['x'])
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
)
await ModelExporter.downloadFromURL(
'http://example.com/cube.glb',
'cube.glb'
)
expect(downloadBlobMock).toHaveBeenCalledWith('cube.glb', blob)
vi.unstubAllGlobals()
})
it('rethrows and shows a toast alert when fetch fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
await expect(
ModelExporter.downloadFromURL('http://example.com/cube.glb', 'cube.glb')
).rejects.toThrow('network')
expect(addAlertMock).toHaveBeenCalledWith(
'toastMessages.failedToDownloadFile'
)
vi.unstubAllGlobals()
})
})
describe('exportGLB', () => {
it('takes the direct-URL fast path when the original URL is already a .glb', async () => {
const blob = new Blob(['x'])
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
)
const model = new THREE.Object3D()
await ModelExporter.exportGLB(
model,
'out.glb',
'http://example.com/api/view?filename=src.glb'
)
expect(downloadBlobMock).toHaveBeenCalledWith('out.glb', blob)
expect(gltfParseMock).not.toHaveBeenCalled()
vi.unstubAllGlobals()
})
it('falls through to GLTFExporter when there is no direct URL', async () => {
gltfParseMock.mockImplementation(
(
_model: unknown,
onDone: (gltf: ArrayBuffer) => void,
_onError: unknown,
options: { binary: boolean }
) => {
expect(options.binary).toBe(true)
onDone(new ArrayBuffer(8))
}
)
const promise = ModelExporter.exportGLB(new THREE.Object3D(), 'out.glb')
await vi.runAllTimersAsync()
await promise
expect(gltfParseMock).toHaveBeenCalled()
expect(downloadBlobMock).toHaveBeenCalledWith('out.glb', expect.any(Blob))
})
it('alerts and rethrows when GLTFExporter rejects', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
gltfParseMock.mockImplementation(
(_model: unknown, _onDone: unknown, onError: (e: Error) => void) =>
onError(new Error('parse fail'))
)
const promise = ModelExporter.exportGLB(new THREE.Object3D(), 'out.glb')
const assertion = expect(promise).rejects.toThrow('parse fail')
await vi.runAllTimersAsync()
await assertion
expect(addAlertMock).toHaveBeenCalledWith(
'toastMessages.failedToExportModel:{"format":"GLB"}'
)
})
})
describe('exportOBJ', () => {
it('uses the direct-URL fast path for matching .obj URLs', async () => {
const blob = new Blob(['x'])
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
)
await ModelExporter.exportOBJ(
new THREE.Object3D(),
'out.obj',
'http://example.com/api/view?filename=src.obj'
)
expect(downloadBlobMock).toHaveBeenCalledWith('out.obj', blob)
expect(objParseMock).not.toHaveBeenCalled()
vi.unstubAllGlobals()
})
it('serializes via OBJExporter and downloads as text when there is no direct URL', async () => {
objParseMock.mockReturnValue('# obj data')
const promise = ModelExporter.exportOBJ(new THREE.Object3D(), 'out.obj')
await vi.runAllTimersAsync()
await promise
expect(objParseMock).toHaveBeenCalled()
expect(downloadBlobMock).toHaveBeenCalledWith('out.obj', expect.any(Blob))
})
it('alerts and rethrows when OBJExporter throws', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
objParseMock.mockImplementation(() => {
throw new Error('obj fail')
})
const promise = ModelExporter.exportOBJ(new THREE.Object3D(), 'out.obj')
const assertion = expect(promise).rejects.toThrow('obj fail')
await vi.runAllTimersAsync()
await assertion
expect(addAlertMock).toHaveBeenCalledWith(
'toastMessages.failedToExportModel:{"format":"OBJ"}'
)
})
})
describe('exportSTL', () => {
it('uses the direct-URL fast path for matching .stl URLs', async () => {
const blob = new Blob(['x'])
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
)
await ModelExporter.exportSTL(
new THREE.Object3D(),
'out.stl',
'http://example.com/api/view?filename=src.stl'
)
expect(downloadBlobMock).toHaveBeenCalledWith('out.stl', blob)
expect(stlParseMock).not.toHaveBeenCalled()
vi.unstubAllGlobals()
})
it('serializes via STLExporter and downloads as text when there is no direct URL', async () => {
stlParseMock.mockReturnValue('solid model')
const promise = ModelExporter.exportSTL(new THREE.Object3D(), 'out.stl')
await vi.runAllTimersAsync()
await promise
expect(stlParseMock).toHaveBeenCalled()
expect(downloadBlobMock).toHaveBeenCalledWith('out.stl', expect.any(Blob))
})
it('alerts and rethrows when STLExporter throws', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
stlParseMock.mockImplementation(() => {
throw new Error('stl fail')
})
const promise = ModelExporter.exportSTL(new THREE.Object3D(), 'out.stl')
const assertion = expect(promise).rejects.toThrow('stl fail')
await vi.runAllTimersAsync()
await assertion
expect(addAlertMock).toHaveBeenCalledWith(
'toastMessages.failedToExportModel:{"format":"STL"}'
)
})
})
})

View File

@@ -0,0 +1,317 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import { RecordingManager } from './RecordingManager'
const { downloadBlobMock } = vi.hoisted(() => ({
downloadBlobMock: vi.fn()
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadBlob: downloadBlobMock
}))
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
// Avoid TextureLoader -> ImageLoader -> new Image() in happy-dom.
class StubTextureLoader {
load() {
return new actual.Texture()
}
}
return { ...actual, TextureLoader: StubTextureLoader }
})
type DataAvailableHandler = (event: { data: Blob }) => void
type StopHandler = () => void
class MockMediaRecorder {
static instances: MockMediaRecorder[] = []
ondataavailable: DataAvailableHandler | null = null
onstop: StopHandler | null = null
state: 'inactive' | 'recording' | 'paused' = 'inactive'
constructor(
public stream: MediaStream,
public options?: MediaRecorderOptions
) {
MockMediaRecorder.instances.push(this)
}
start = vi.fn(() => {
this.state = 'recording'
})
stop = vi.fn(() => {
this.state = 'inactive'
this.onstop?.()
})
pushChunk(blob: Blob) {
this.ondataavailable?.({ data: blob })
}
}
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
} satisfies EventManagerInterface
}
function makeStream(): MediaStream {
const tracks: { stop: ReturnType<typeof vi.fn> }[] = [{ stop: vi.fn() }]
return {
getTracks: () => tracks
} as unknown as MediaStream
}
function makeRenderer(): THREE.WebGLRenderer {
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 600
return { domElement: canvas } as unknown as THREE.WebGLRenderer
}
describe('RecordingManager', () => {
let scene: THREE.Scene
let renderer: THREE.WebGLRenderer
let events: ReturnType<typeof makeMockEventManager>
let manager: RecordingManager
let rafSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
MockMediaRecorder.instances = []
vi.stubGlobal('MediaRecorder', MockMediaRecorder)
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => 'blob:mock'),
revokeObjectURL: vi.fn()
})
// happy-dom canvases lack captureStream; stub it on the prototype so
// every canvas the production code creates gets a usable stream.
vi.spyOn(
HTMLCanvasElement.prototype as unknown as {
captureStream: (fps?: number) => MediaStream
},
'captureStream'
).mockImplementation(makeStream)
// happy-dom returns null from getContext('2d'); production code throws
// without it. Provide a minimal context with the methods the manager calls.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn()
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
scene = new THREE.Scene()
renderer = makeRenderer()
events = makeMockEventManager()
manager = new RecordingManager(scene, renderer, events)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('construction', () => {
it('adds a hidden recording indicator sprite to the scene', () => {
const sprite = scene.children.find((c) => c instanceof THREE.Sprite) as
| THREE.Sprite
| undefined
expect(sprite).toBeDefined()
expect(sprite!.visible).toBe(false)
})
})
describe('startRecording', () => {
it('initializes a MediaRecorder, marks recording state, and emits recordingStarted', async () => {
await manager.startRecording()
expect(MockMediaRecorder.instances).toHaveLength(1)
expect(MockMediaRecorder.instances[0].start).toHaveBeenCalledWith(100)
expect(manager.getIsRecording()).toBe(true)
expect(events.emitEvent).toHaveBeenCalledWith('recordingStarted', null)
})
it('shows the recording indicator sprite', async () => {
const sprite = scene.children.find(
(c) => c instanceof THREE.Sprite
) as THREE.Sprite
await manager.startRecording()
expect(sprite.visible).toBe(true)
})
it('begins capturing frames via requestAnimationFrame', async () => {
await manager.startRecording()
expect(rafSpy).toHaveBeenCalled()
})
it('is idempotent — a second startRecording while already recording is ignored', async () => {
await manager.startRecording()
await manager.startRecording()
expect(MockMediaRecorder.instances).toHaveLength(1)
})
it('emits recordingError when MediaRecorder construction fails', async () => {
vi.stubGlobal(
'MediaRecorder',
class {
constructor() {
throw new Error('codec not supported')
}
}
)
await manager.startRecording()
expect(events.emitEvent).toHaveBeenCalledWith(
'recordingError',
expect.any(Error)
)
expect(manager.getIsRecording()).toBe(false)
})
})
describe('stopRecording', () => {
it('is a no-op when not currently recording', () => {
manager.stopRecording()
expect(events.emitEvent).not.toHaveBeenCalledWith(
'recordingStopped',
expect.anything()
)
})
it('hides the indicator, clears recording state, and emits recordingStopped', async () => {
await manager.startRecording()
const sprite = scene.children.find(
(c) => c instanceof THREE.Sprite
) as THREE.Sprite
manager.stopRecording()
expect(sprite.visible).toBe(false)
expect(manager.getIsRecording()).toBe(false)
expect(events.emitEvent).toHaveBeenCalledWith(
'recordingStopped',
expect.objectContaining({ hasRecording: false })
)
})
it('reports a non-zero duration after recording', async () => {
await manager.startRecording()
// Force a known startTime so duration math is deterministic.
;(
manager as unknown as { recordingStartTime: number }
).recordingStartTime = Date.now() - 2000
manager.stopRecording()
expect(manager.getRecordingDuration()).toBeGreaterThanOrEqual(2)
})
})
describe('hasRecording / getRecordingData', () => {
it('reports no recording until chunks have been received', async () => {
await manager.startRecording()
expect(manager.hasRecording()).toBe(false)
expect(manager.getRecordingData()).toBeNull()
})
it('returns a blob URL once chunks exist', async () => {
await manager.startRecording()
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
expect(manager.hasRecording()).toBe(true)
expect(manager.getRecordingData()).toBe('blob:mock')
})
it('does not push zero-byte chunks', async () => {
await manager.startRecording()
MockMediaRecorder.instances[0].pushChunk(new Blob([]))
expect(manager.hasRecording()).toBe(false)
})
})
describe('exportRecording', () => {
it('emits a recordingError when there is nothing to export', () => {
manager.exportRecording()
expect(events.emitEvent).toHaveBeenCalledWith(
'recordingError',
expect.any(Error)
)
expect(downloadBlobMock).not.toHaveBeenCalled()
})
it('downloads the blob with the requested filename and emits exportingRecording then recordingExported', async () => {
await manager.startRecording()
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
manager.exportRecording('clip.webm')
expect(downloadBlobMock).toHaveBeenCalledWith(
'clip.webm',
expect.any(Blob)
)
expect(events.emitEvent).toHaveBeenCalledWith('exportingRecording', null)
expect(events.emitEvent).toHaveBeenCalledWith('recordingExported', null)
})
it('uses the default filename when none is provided', async () => {
await manager.startRecording()
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
manager.exportRecording()
expect(downloadBlobMock).toHaveBeenCalledWith(
'scene-recording.mp4',
expect.any(Blob)
)
})
})
describe('clearRecording', () => {
it('drops all chunks, resets duration, and emits recordingCleared', async () => {
await manager.startRecording()
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
manager.stopRecording()
manager.clearRecording()
expect(manager.hasRecording()).toBe(false)
expect(manager.getRecordingDuration()).toBe(0)
expect(events.emitEvent).toHaveBeenCalledWith('recordingCleared', null)
})
})
describe('dispose', () => {
it('removes the indicator sprite from the scene and disposes its material', async () => {
const sprite = scene.children.find(
(c) => c instanceof THREE.Sprite
) as THREE.Sprite
const disposeSpy = vi.spyOn(
sprite.material as THREE.SpriteMaterial,
'dispose'
)
manager.dispose()
expect(scene.children).not.toContain(sprite)
expect(disposeSpy).toHaveBeenCalled()
})
it('stops an in-flight recording before disposing', async () => {
await manager.startRecording()
manager.dispose()
expect(MockMediaRecorder.instances[0].stop).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,546 @@
import * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import Load3dUtils from './Load3dUtils'
import { SceneManager } from './SceneManager'
const { mockTextureLoad } = vi.hoisted(() => ({
mockTextureLoad: vi.fn()
}))
vi.mock('./Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn()
}
}))
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class StubTextureLoader {
load = mockTextureLoad
}
return { ...actual, TextureLoader: StubTextureLoader }
})
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
} satisfies EventManagerInterface
}
function makeRenderer() {
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
configurable: true,
value: 800
})
Object.defineProperty(canvas, 'clientHeight', {
configurable: true,
value: 600
})
canvas.width = 800
canvas.height = 600
vi.spyOn(canvas, 'toDataURL').mockReturnValue('data:image/png;base64,FAKE')
return {
domElement: canvas,
setClearColor: vi.fn(),
setSize: vi.fn(),
render: vi.fn(),
clear: vi.fn(),
getClearColor: vi.fn().mockReturnValue(new THREE.Color(0xffffff)),
getClearAlpha: vi.fn().mockReturnValue(1),
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 1,
outputColorSpace: THREE.SRGBColorSpace
} as unknown as THREE.WebGLRenderer
}
function makeImageTexture(width = 200, height = 100): THREE.Texture {
const texture = new THREE.Texture()
;(texture as unknown as { image: { width: number; height: number } }).image =
{
width,
height
}
return texture
}
describe('SceneManager', () => {
let renderer: THREE.WebGLRenderer
let camera: THREE.PerspectiveCamera
let events: ReturnType<typeof makeMockEventManager>
let manager: SceneManager
beforeEach(() => {
vi.clearAllMocks()
renderer = makeRenderer()
camera = new THREE.PerspectiveCamera()
events = makeMockEventManager()
manager = new SceneManager(
renderer,
() => camera,
() => ({}) as unknown as OrbitControls,
events
)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('construction', () => {
it('builds the main scene with a grid helper at the origin', () => {
expect(manager.scene).toBeInstanceOf(THREE.Scene)
expect(manager.gridHelper).toBeInstanceOf(THREE.GridHelper)
expect(manager.scene.children).toContain(manager.gridHelper)
})
it('builds a separate background scene with a tiled mesh', () => {
expect(manager.backgroundScene).toBeInstanceOf(THREE.Scene)
expect(manager.backgroundMesh).toBeInstanceOf(THREE.Mesh)
expect(manager.backgroundScene.children).toContain(manager.backgroundMesh)
expect(manager.backgroundColorMaterial).toBeInstanceOf(
THREE.MeshBasicMaterial
)
})
it('initializes the background to color mode at the default color', () => {
expect(manager.currentBackgroundType).toBe('color')
expect(manager.currentBackgroundColor).toBe('#282828')
expect(manager.backgroundRenderMode).toBe('tiled')
})
})
describe('toggleGrid', () => {
it('hides and shows the grid and emits showGridChange', () => {
manager.toggleGrid(false)
expect(manager.gridHelper.visible).toBe(false)
expect(events.emitEvent).toHaveBeenCalledWith('showGridChange', false)
manager.toggleGrid(true)
expect(manager.gridHelper.visible).toBe(true)
expect(events.emitEvent).toHaveBeenCalledWith('showGridChange', true)
})
})
describe('setBackgroundColor', () => {
it('updates the material color and emits backgroundColorChange', () => {
manager.setBackgroundColor('#ff0000')
expect(manager.currentBackgroundColor).toBe('#ff0000')
expect(manager.currentBackgroundType).toBe('color')
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundColorChange',
'#ff0000'
)
})
it('clears any prior texture-based scene background', () => {
manager.scene.background = makeImageTexture()
manager.setBackgroundColor('#abcdef')
expect(manager.scene.background).toBeNull()
})
it('demotes panorama mode back to tiled and emits the change', () => {
manager.backgroundRenderMode = 'panorama'
manager.setBackgroundColor('#abcdef')
expect(manager.backgroundRenderMode).toBe('tiled')
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundRenderModeChange',
'tiled'
)
})
it('disposes any prior background texture', () => {
const texture = makeImageTexture()
const dispose = vi.spyOn(texture, 'dispose')
manager.backgroundTexture = texture
manager.setBackgroundColor('#000000')
expect(dispose).toHaveBeenCalled()
expect(manager.backgroundTexture).toBeNull()
})
})
describe('setBackgroundImage', () => {
it('falls back to setBackgroundColor when given an empty path', async () => {
const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor')
await manager.setBackgroundImage('')
expect(setBackgroundColor).toHaveBeenCalledWith(
manager.currentBackgroundColor
)
})
it('emits a loading-start event before fetching', async () => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/view?bg.png')
mockTextureLoad.mockImplementation(
(_url: string, resolve: (t: THREE.Texture) => void) =>
resolve(makeImageTexture())
)
const promise = manager.setBackgroundImage('bg.png')
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundImageLoadingStart',
null
)
await promise
})
it('rewrites temp/output subfolders to a flat path with the right type', async () => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['temp', 'out.png'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view?out.png')
mockTextureLoad.mockImplementation(
(_url: string, resolve: (t: THREE.Texture) => void) =>
resolve(makeImageTexture())
)
await manager.setBackgroundImage('temp/out.png')
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'',
'out.png',
'temp'
)
})
it('prefixes /api when getResourceURL returns a non-/api URL', async () => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view?bg.png')
const captured: string[] = []
mockTextureLoad.mockImplementation(
(url: string, resolve: (t: THREE.Texture) => void) => {
captured.push(url)
resolve(makeImageTexture())
}
)
await manager.setBackgroundImage('bg.png')
expect(captured[0]).toBe('/api/view?bg.png')
})
it('in tiled mode, swaps the background mesh material to use the new texture', async () => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
const texture = makeImageTexture()
mockTextureLoad.mockImplementation(
(_url: string, resolve: (t: THREE.Texture) => void) => resolve(texture)
)
await manager.setBackgroundImage('bg.png')
expect(manager.currentBackgroundType).toBe('image')
expect(manager.backgroundTexture).toBe(texture)
expect(manager.backgroundMesh!.material).not.toBe(
manager.backgroundColorMaterial
)
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundImageChange',
'bg.png'
)
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundImageLoadingEnd',
null
)
})
it('in panorama mode, assigns the texture as the scene background with equirectangular mapping', async () => {
manager.backgroundRenderMode = 'panorama'
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
const texture = makeImageTexture()
mockTextureLoad.mockImplementation(
(_url: string, resolve: (t: THREE.Texture) => void) => resolve(texture)
)
await manager.setBackgroundImage('bg.png')
expect(manager.scene.background).toBe(texture)
expect(texture.mapping).toBe(THREE.EquirectangularReflectionMapping)
})
it('disposes a previously loaded background texture before assigning the new one', async () => {
const previous = makeImageTexture()
const disposePrev = vi.spyOn(previous, 'dispose')
manager.backgroundTexture = previous
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
mockTextureLoad.mockImplementation(
(_url: string, resolve: (t: THREE.Texture) => void) =>
resolve(makeImageTexture())
)
await manager.setBackgroundImage('bg.png')
expect(disposePrev).toHaveBeenCalled()
})
it('on load failure, emits loading-end and falls back to a color background', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
mockTextureLoad.mockImplementation(
(
_url: string,
_resolve: unknown,
_onProgress: unknown,
reject: (e: Error) => void
) => reject(new Error('load failed'))
)
const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor')
await manager.setBackgroundImage('bg.png')
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundImageLoadingEnd',
null
)
expect(setBackgroundColor).toHaveBeenCalledWith(
manager.currentBackgroundColor
)
})
})
describe('removeBackgroundImage', () => {
it('reverts to the current color and emits loading-end', () => {
const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor')
manager.removeBackgroundImage()
expect(setBackgroundColor).toHaveBeenCalledWith(
manager.currentBackgroundColor
)
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundImageLoadingEnd',
null
)
})
})
describe('setBackgroundRenderMode', () => {
it('is a no-op when the requested mode equals the current mode', () => {
events.emitEvent.mockClear()
manager.setBackgroundRenderMode('tiled')
expect(events.emitEvent).not.toHaveBeenCalled()
})
it('switches to panorama on a color background and just emits the change', () => {
manager.setBackgroundRenderMode('panorama')
expect(manager.backgroundRenderMode).toBe('panorama')
expect(events.emitEvent).toHaveBeenCalledWith(
'backgroundRenderModeChange',
'panorama'
)
})
it('promotes an image background to scene.background when switching to panorama', () => {
manager.currentBackgroundType = 'image'
const texture = makeImageTexture()
manager.backgroundTexture = texture
manager.setBackgroundRenderMode('panorama')
expect(manager.scene.background).toBe(texture)
expect(texture.mapping).toBe(THREE.EquirectangularReflectionMapping)
})
it('demotes back to tiled by clearing scene.background and updating the mesh map', () => {
manager.currentBackgroundType = 'image'
const texture = makeImageTexture()
manager.backgroundTexture = texture
manager.scene.background = texture
manager.backgroundRenderMode = 'panorama'
manager.setBackgroundRenderMode('tiled')
expect(manager.scene.background).toBeNull()
const mat = manager.backgroundMesh!.material as THREE.MeshBasicMaterial
expect(mat.map).toBe(texture)
// THREE's `needsUpdate` is a write-only setter — reading is undefined.
// Asserting the map swap is sufficient to validate the demote path.
})
})
describe('updateBackgroundSize', () => {
it('does nothing without a texture or mesh', () => {
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial()
)
const before = mesh.scale.toArray()
manager.updateBackgroundSize(null, mesh, 100, 100)
expect(mesh.scale.toArray()).toEqual(before)
})
it('does nothing without a mesh', () => {
expect(() =>
manager.updateBackgroundSize(makeImageTexture(), null, 100, 100)
).not.toThrow()
})
it('does nothing when the mesh material has no map', () => {
const texture = makeImageTexture(400, 100)
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial() // no map
)
const before = mesh.scale.toArray()
manager.updateBackgroundSize(texture, mesh, 200, 100)
expect(mesh.scale.toArray()).toEqual(before)
})
it('scales horizontally when the image is wider than the target', () => {
const texture = makeImageTexture(400, 100) // imageAspect = 4
const mat = new THREE.MeshBasicMaterial({ map: texture })
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat)
manager.updateBackgroundSize(texture, mesh, 200, 100) // targetAspect = 2
expect(mesh.scale.x).toBeCloseTo(2)
expect(mesh.scale.y).toBe(1)
})
it('scales vertically when the image is taller than the target', () => {
const texture = makeImageTexture(100, 400) // imageAspect = 0.25
const mat = new THREE.MeshBasicMaterial({ map: texture })
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat)
manager.updateBackgroundSize(texture, mesh, 200, 100) // targetAspect = 2
expect(mesh.scale.x).toBe(1)
expect(mesh.scale.y).toBeCloseTo(8)
})
})
describe('handleResize', () => {
it('updates background size when an image background is active', () => {
const texture = makeImageTexture(400, 100)
manager.backgroundTexture = texture
manager.currentBackgroundType = 'image'
;(manager.backgroundMesh!.material as THREE.MeshBasicMaterial).map =
texture
const update = vi.spyOn(manager, 'updateBackgroundSize')
manager.handleResize(800, 600)
expect(update).toHaveBeenCalledWith(
texture,
manager.backgroundMesh,
800,
600
)
})
it('does nothing when only a color background is active', () => {
const update = vi.spyOn(manager, 'updateBackgroundSize')
manager.handleResize(800, 600)
expect(update).not.toHaveBeenCalled()
})
})
describe('getCurrentBackgroundInfo', () => {
it('returns the color when in color mode', () => {
manager.setBackgroundColor('#abc123')
expect(manager.getCurrentBackgroundInfo()).toEqual({
type: 'color',
value: '#abc123'
})
})
it('returns an empty value when in image mode', () => {
manager.currentBackgroundType = 'image'
expect(manager.getCurrentBackgroundInfo()).toEqual({
type: 'image',
value: ''
})
})
})
describe('captureScene', () => {
it('returns three data URLs and restores the renderer to its original state', async () => {
const result = await manager.captureScene(400, 300)
expect(result.scene).toBe('data:image/png;base64,FAKE')
expect(result.mask).toBe('data:image/png;base64,FAKE')
expect(result.normal).toBe('data:image/png;base64,FAKE')
// Renderer.setSize is called once with the capture size and once to restore.
expect(renderer.setSize).toHaveBeenCalledWith(400, 300)
expect(renderer.setSize).toHaveBeenLastCalledWith(800, 600)
})
it('restores grid visibility after rendering the normal pass', async () => {
manager.gridHelper.visible = true
await manager.captureScene(100, 100)
expect(manager.gridHelper.visible).toBe(true)
})
it('rejects when the renderer throws during capture', async () => {
vi.mocked(renderer.render).mockImplementationOnce(() => {
throw new Error('renderer fail')
})
await expect(manager.captureScene(100, 100)).rejects.toThrow(
'renderer fail'
)
})
})
describe('dispose', () => {
it('disposes the texture, color material, and mesh resources, then clears scenes', () => {
const texture = makeImageTexture()
const disposeTexture = vi.spyOn(texture, 'dispose')
manager.backgroundTexture = texture
const disposeColorMat = vi.spyOn(
manager.backgroundColorMaterial!,
'dispose'
)
const disposeGeometry = vi.spyOn(
manager.backgroundMesh!.geometry,
'dispose'
)
manager.dispose()
expect(disposeTexture).toHaveBeenCalled()
expect(disposeColorMat).toHaveBeenCalled()
expect(disposeGeometry).toHaveBeenCalled()
expect(manager.scene.children).toHaveLength(0)
expect(manager.backgroundScene.children).toHaveLength(0)
})
it('clears the scene background when one was set', () => {
manager.scene.background = makeImageTexture()
manager.dispose()
expect(manager.scene.background).toBeNull()
})
})
})

View File

@@ -0,0 +1,258 @@
import * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import { ViewHelperManager } from './ViewHelperManager'
interface MockViewHelperInstance {
camera: THREE.Camera
domElement: HTMLElement
animating: boolean
visible: boolean
center: THREE.Vector3 | null
update: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
handleClick: ReturnType<typeof vi.fn>
}
const { viewHelperInstances, mockHandleClick } = vi.hoisted(() => ({
viewHelperInstances: [] as MockViewHelperInstance[],
mockHandleClick: vi.fn()
}))
vi.mock('three/examples/jsm/helpers/ViewHelper', () => {
class ViewHelper {
animating = false
visible = true
center: THREE.Vector3 | null = null
update = vi.fn()
dispose = vi.fn()
handleClick = mockHandleClick
constructor(
public camera: THREE.Camera,
public domElement: HTMLElement
) {
viewHelperInstances.push(this as unknown as MockViewHelperInstance)
}
}
return { ViewHelper }
})
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
} satisfies EventManagerInterface
}
function makeOrbitControls(target = new THREE.Vector3()) {
return { target } as unknown as OrbitControls
}
describe('ViewHelperManager', () => {
let events: ReturnType<typeof makeMockEventManager>
let camera: THREE.PerspectiveCamera
let controls: OrbitControls
let manager: ViewHelperManager
beforeEach(() => {
vi.clearAllMocks()
viewHelperInstances.length = 0
events = makeMockEventManager()
camera = new THREE.PerspectiveCamera()
controls = makeOrbitControls()
manager = new ViewHelperManager(
{} as THREE.WebGLRenderer,
() => camera,
() => controls,
events
)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('createViewHelper', () => {
it('appends a 128x128 absolutely-positioned container to the parent element', () => {
const parent = document.createElement('div')
manager.createViewHelper(parent)
expect(manager.viewHelperContainer.parentNode).toBe(parent)
expect(manager.viewHelperContainer.style.width).toBe('128px')
expect(manager.viewHelperContainer.style.height).toBe('128px')
expect(manager.viewHelperContainer.style.position).toBe('absolute')
})
it('instantiates ViewHelper with the active camera and binds its center to the controls target', () => {
const target = new THREE.Vector3(1, 2, 3)
controls = makeOrbitControls(target)
manager = new ViewHelperManager(
{} as THREE.WebGLRenderer,
() => camera,
() => controls,
events
)
manager.createViewHelper(document.createElement('div'))
expect(viewHelperInstances).toHaveLength(1)
expect(viewHelperInstances[0].camera).toBe(camera)
expect(manager.viewHelper.center).toBe(target)
})
it('routes pointerup events to ViewHelper.handleClick and stops propagation', () => {
const parent = document.createElement('div')
const propagated = vi.fn()
parent.addEventListener('pointerup', propagated)
manager.createViewHelper(parent)
const event = new PointerEvent('pointerup', { bubbles: true })
manager.viewHelperContainer.dispatchEvent(event)
expect(mockHandleClick).toHaveBeenCalledWith(event)
expect(propagated).not.toHaveBeenCalled()
})
it('stops propagation of pointerdown events without forwarding them to ViewHelper', () => {
const parent = document.createElement('div')
const propagated = vi.fn()
parent.addEventListener('pointerdown', propagated)
manager.createViewHelper(parent)
manager.viewHelperContainer.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true })
)
expect(propagated).not.toHaveBeenCalled()
expect(mockHandleClick).not.toHaveBeenCalled()
})
})
describe('update', () => {
it('does nothing when ViewHelper is not animating', () => {
manager.createViewHelper(document.createElement('div'))
manager.viewHelper.animating = false
manager.update(0.5)
expect(manager.viewHelper.update).not.toHaveBeenCalled()
expect(events.emitEvent).not.toHaveBeenCalled()
})
it('drives the animation while it is in progress without emitting yet', () => {
manager.createViewHelper(document.createElement('div'))
manager.viewHelper.animating = true
manager.update(0.25)
expect(manager.viewHelper.update).toHaveBeenCalledWith(0.25)
expect(events.emitEvent).not.toHaveBeenCalled()
})
it('emits cameraChanged with a perspective state when the animation just finished', () => {
manager.createViewHelper(document.createElement('div'))
camera.position.set(1, 2, 3)
camera.zoom = 1.5
controls.target.set(4, 5, 6)
manager.viewHelper.animating = true
;(
manager.viewHelper.update as unknown as {
mockImplementation(fn: () => void): void
}
).mockImplementation(() => {
manager.viewHelper.animating = false
})
manager.update(0)
expect(events.emitEvent).toHaveBeenCalledWith('cameraChanged', {
position: expect.objectContaining({ x: 1, y: 2, z: 3 }),
target: expect.objectContaining({ x: 4, y: 5, z: 6 }),
zoom: 1.5,
cameraType: 'perspective'
})
})
it('reports orthographic when the active camera is an OrthographicCamera', () => {
const ortho = new THREE.OrthographicCamera()
ortho.zoom = 0.5
manager = new ViewHelperManager(
{} as THREE.WebGLRenderer,
() => ortho,
() => controls,
events
)
manager.createViewHelper(document.createElement('div'))
manager.viewHelper.animating = true
;(
manager.viewHelper.update as unknown as {
mockImplementation(fn: () => void): void
}
).mockImplementation(() => {
manager.viewHelper.animating = false
})
manager.update(0)
expect(events.emitEvent).toHaveBeenCalledWith(
'cameraChanged',
expect.objectContaining({ cameraType: 'orthographic', zoom: 0.5 })
)
})
})
describe('visibleViewHelper', () => {
it('shows the helper and unhides the container when called with true', () => {
manager.createViewHelper(document.createElement('div'))
manager.viewHelper.visible = false
manager.viewHelperContainer.style.display = 'none'
manager.visibleViewHelper(true)
expect(manager.viewHelper.visible).toBe(true)
expect(manager.viewHelperContainer.style.display).toBe('block')
})
it('hides the helper and the container when called with false', () => {
manager.createViewHelper(document.createElement('div'))
manager.visibleViewHelper(false)
expect(manager.viewHelper.visible).toBe(false)
expect(manager.viewHelperContainer.style.display).toBe('none')
})
})
describe('recreateViewHelper', () => {
it('disposes the old helper and constructs a new one bound to the controls target', () => {
manager.createViewHelper(document.createElement('div'))
const oldHelper = manager.viewHelper
const newTarget = new THREE.Vector3(9, 9, 9)
controls.target.copy(newTarget)
manager.recreateViewHelper()
expect(oldHelper.dispose).toHaveBeenCalled()
expect(manager.viewHelper).not.toBe(oldHelper)
expect(viewHelperInstances).toHaveLength(2)
expect(manager.viewHelper.center).toBe(controls.target)
})
})
describe('dispose', () => {
it('disposes the helper and removes the container from its parent', () => {
const parent = document.createElement('div')
manager.createViewHelper(parent)
const helper = manager.viewHelper
manager.dispose()
expect(helper.dispose).toHaveBeenCalled()
expect(manager.viewHelperContainer.parentNode).toBeNull()
})
})
})

View File

@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type Load3d from './Load3d'
import { createExportMenuItems } from './exportMenuHelper'
const { contextMenuMock, addToastMock, addAlertMock } = vi.hoisted(() => ({
contextMenuMock: vi.fn(),
addToastMock: vi.fn(),
addAlertMock: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: addToastMock, addAlert: addAlertMock })
}))
vi.mock(import('@/lib/litegraph/src/litegraph'), async (importOriginal) => {
const actual = await importOriginal()
class MockContextMenu {
constructor(...args: unknown[]) {
contextMenuMock(...args)
}
}
// Replace ContextMenu in-place on the real LiteGraph singleton so consumers
// that import other members keep getting the real implementations.
;(actual.LiteGraph as unknown as { ContextMenu: unknown }).ContextMenu =
MockContextMenu
return actual
})
function makeLoad3d(
exportImpl: (format: string) => Promise<void> = vi
.fn()
.mockResolvedValue(undefined)
): Load3d {
return { exportModel: exportImpl } as unknown as Load3d
}
describe('createExportMenuItems', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns a separator followed by a Save submenu', () => {
const items = createExportMenuItems(makeLoad3d())
expect(items).toHaveLength(2)
expect(items[0]).toBeNull()
expect(items[1]).toMatchObject({
content: 'Save',
has_submenu: true
})
})
it('opens a submenu with GLB, OBJ, STL when the Save item is invoked', () => {
const items = createExportMenuItems(makeLoad3d())
const saveItem = items[1]!
;(saveItem.callback as (...args: unknown[]) => void)(
undefined,
{},
undefined,
undefined
)
expect(contextMenuMock).toHaveBeenCalledOnce()
const submenuOptions = contextMenuMock.mock.calls[0][0]
expect(submenuOptions.map((o: { content: string }) => o.content)).toEqual([
'GLB',
'OBJ',
'STL'
])
})
it('forwards the parent menu and event when opening the submenu', () => {
const items = createExportMenuItems(makeLoad3d())
const event = { x: 100 } as unknown as MouseEvent
const parentMenu = { id: 'prev' }
;(items[1]!.callback as (...args: unknown[]) => void)(
undefined,
{},
event,
parentMenu
)
expect(contextMenuMock).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ event, parentMenu })
)
})
it.each([
['GLB', 'glb'],
['OBJ', 'obj'],
['STL', 'stl']
])(
'invokes load3d.exportModel(%s) and shows a success toast when the %s submenu item is clicked',
async (label, value) => {
const exportModel = vi.fn().mockResolvedValue(undefined)
const items = createExportMenuItems(makeLoad3d(exportModel))
;(items[1]!.callback as (...args: unknown[]) => void)(
undefined,
{},
undefined,
undefined
)
const submenuOptions = contextMenuMock.mock.calls[0][0]
const item = submenuOptions.find(
(o: { content: string }) => o.content === label
)
item.callback()
await vi.waitFor(() => expect(exportModel).toHaveBeenCalledWith(value))
await vi.waitFor(() =>
expect(addToastMock).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: `toastMessages.exportSuccess:${JSON.stringify({ format: label })}`
})
)
)
expect(addAlertMock).not.toHaveBeenCalled()
}
)
it('shows an alert toast and logs when exportModel rejects', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const exportModel = vi.fn().mockRejectedValue(new Error('boom'))
const items = createExportMenuItems(makeLoad3d(exportModel))
;(items[1]!.callback as (...args: unknown[]) => void)(
undefined,
{},
undefined,
undefined
)
const glb = contextMenuMock.mock.calls[0][0].find(
(o: { content: string }) => o.content === 'GLB'
)
glb.callback()
await vi.waitFor(() =>
expect(addAlertMock).toHaveBeenCalledWith(
`toastMessages.failedToExportModel:${JSON.stringify({ format: 'GLB' })}`
)
)
expect(consoleError).toHaveBeenCalledWith(
'Export failed:',
expect.any(Error)
)
expect(addToastMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
const { registerExtensionMock, enabledExtensionsGetter } = vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
enabledExtensionsGetter: vi.fn(() => [] as ComfyExtension[])
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension: registerExtensionMock })
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({
get enabledExtensions() {
return enabledExtensionsGetter()
}
})
}))
vi.mock('@/scripts/app', () => ({
app: { __mockApp: true }
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))
type Hook = (
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef,
app?: unknown
) => Promise<void> | void
async function loadLazyExtensionFresh(): Promise<{ hook: Hook }> {
vi.resetModules()
registerExtensionMock.mockClear()
enabledExtensionsGetter.mockReset().mockReturnValue([])
await import('@/extensions/core/load3dLazy')
const ext = registerExtensionMock.mock.calls[0][0] as ComfyExtension
return { hook: ext.beforeRegisterNodeDef as Hook }
}
function makeNodeDef(
name: string,
extra: Partial<ComfyNodeDef> = {}
): ComfyNodeDef {
return {
name,
display_name: name,
category: '',
output: [],
output_is_list: [],
output_name: [],
python_module: '',
description: '',
...extra
} as ComfyNodeDef
}
describe('load3dLazy', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('registers a single Comfy.Load3DLazy extension on import', async () => {
await loadLazyExtensionFresh()
expect(registerExtensionMock).toHaveBeenCalledOnce()
const ext = registerExtensionMock.mock.calls[0][0] as ComfyExtension
expect(ext.name).toBe('Comfy.Load3DLazy')
expect(typeof ext.beforeRegisterNodeDef).toBe('function')
})
it('skips loading and mutation for non-3D node defs', async () => {
const { hook } = await loadLazyExtensionFresh()
await hook({} as typeof LGraphNode, makeNodeDef('PlainNode'))
// No diff was ever computed because the early-return branch was taken.
expect(enabledExtensionsGetter).not.toHaveBeenCalled()
})
it.each(['Load3D', 'Preview3D', 'SaveGLB'])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {
const { hook } = await loadLazyExtensionFresh()
await hook({} as typeof LGraphNode, makeNodeDef(nodeType))
// The lazy-load path always reads enabledExtensions once for the diff.
expect(enabledExtensionsGetter).toHaveBeenCalled()
}
)
it('injects mesh_upload spec flags into the model_file widget for Load3D nodes', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3D', {
input: {
required: { model_file: ['STRING', {}] }
}
} as Partial<ComfyNodeDef>)
await hook({} as typeof LGraphNode, nodeData)
const spec = (
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
)[1]
expect(spec.mesh_upload).toBe(true)
expect(spec.upload_subfolder).toBe('3d')
})
it('does not throw when a Load3D node has no model_file widget spec', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3D', {
input: { required: {} }
} as Partial<ComfyNodeDef>)
await expect(
hook({} as typeof LGraphNode, nodeData)
).resolves.toBeUndefined()
})
it('does not mutate model_file for non-Load3D 3D node types', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Preview3D', {
input: {
required: { model_file: ['STRING', { existing: true }] }
}
} as Partial<ComfyNodeDef>)
await hook({} as typeof LGraphNode, nodeData)
const spec = (
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
)[1]
expect(spec.mesh_upload).toBeUndefined()
})
it('replays beforeRegisterNodeDef of newly registered extensions, passing the app reference', async () => {
const newExtension: ComfyExtension = {
name: 'Inner',
beforeRegisterNodeDef: vi.fn()
}
// First call (snapshotting `before`) sees an empty list; second call
// (computing the diff after dynamic imports) sees the new extension.
enabledExtensionsGetter
.mockReturnValueOnce([])
.mockReturnValueOnce([newExtension])
const { hook } = await loadLazyExtensionFresh()
enabledExtensionsGetter
.mockReturnValueOnce([])
.mockReturnValueOnce([newExtension])
const nodeData = makeNodeDef('Preview3D')
await hook({ id: 1 } as unknown as typeof LGraphNode, nodeData)
expect(newExtension.beforeRegisterNodeDef).toHaveBeenCalledWith(
{ id: 1 },
nodeData,
expect.objectContaining({ __mockApp: true })
)
})
it('does not replay extensions that were already registered before lazy loading', async () => {
const preexisting: ComfyExtension = {
name: 'PreExisting',
beforeRegisterNodeDef: vi.fn()
}
enabledExtensionsGetter.mockReturnValue([preexisting])
const { hook } = await loadLazyExtensionFresh()
enabledExtensionsGetter.mockReturnValue([preexisting])
await hook({} as typeof LGraphNode, makeNodeDef('Load3D'))
expect(preexisting.beforeRegisterNodeDef).not.toHaveBeenCalled()
})
})

View File

@@ -230,6 +230,7 @@
"warning": "Warning",
"name": "Name",
"category": "Category",
"categories": "Categories",
"sort": "Sort",
"source": "Source",
"filter": "Filter",

View File

@@ -44,7 +44,7 @@
:context="{ type: assetType }"
class="absolute inset-0"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@download="asset && actions.downloadAssets([asset])"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"

View File

@@ -31,8 +31,7 @@ vi.mock('@/utils/loaderNodeUtil', () => ({
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAsset: vi.fn(),
downloadMultipleAssets: vi.fn(),
downloadAssets: vi.fn(),
openWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
copyJobId: vi.fn(),
@@ -185,7 +184,7 @@ describe('MediaAssetContextMenu', () => {
unmount()
})
it('routes Download through downloadMultipleAssets so multi-output jobs zip', async () => {
it('routes Download through downloadAssets so multi-output jobs zip', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)
@@ -195,10 +194,7 @@ describe('MediaAssetContextMenu', () => {
item: downloadItem
})
expect(mediaAssetActions.downloadMultipleAssets).toHaveBeenCalledWith([
asset
])
expect(mediaAssetActions.downloadAsset).not.toHaveBeenCalled()
expect(mediaAssetActions.downloadAssets).toHaveBeenCalledWith([asset])
unmount()
})

View File

@@ -217,7 +217,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
items.push({
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
command: () => actions.downloadMultipleAssets([asset])
command: () => actions.downloadAssets([asset])
})
// Separator before workflow actions (only if there are workflow actions)

View File

@@ -2,9 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, provide, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
import { useMediaAssetActions } from './useMediaAssetActions'
// Use vi.hoisted to create a mutable reference for isCloud
@@ -13,6 +16,11 @@ const mockIsCloud = vi.hoisted(() => ({ value: false }))
// Track the filename passed to createAnnotatedPath
const capturedFilenames = vi.hoisted(() => ({ values: [] as string[] }))
const mockDownloadFile = vi.hoisted(() => vi.fn())
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: mockDownloadFile
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
@@ -168,13 +176,58 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
}
}
function createMockMediaAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
...createMockAsset(),
kind: 'image',
src: 'https://example.com/default-preview.png',
...overrides
}
}
function mountMediaActions(asset?: AssetMeta) {
let actions: ReturnType<typeof useMediaAssetActions> | undefined
const ChildComponent = defineComponent({
setup() {
actions = useMediaAssetActions()
return () => null
}
})
const HostComponent = defineComponent({
setup() {
provide(MediaAssetKey, {
asset: ref(asset),
context: ref({ type: 'input' as const }),
isVideoPlaying: ref(false),
showVideoControls: ref(false)
})
return () => h(ChildComponent)
}
})
const host = document.createElement('div')
const app = createApp(HostComponent)
app.mount(host)
if (!actions) throw new Error('media asset actions not initialized')
return {
actions,
unmount: () => app.unmount()
}
}
describe('useMediaAssetActions', () => {
beforeEach(() => {
vi.resetModules()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
capturedFilenames.values = []
mockIsCloud.value = false
mockGetOutputAssetMetadata.mockReset()
mockGetOutputAssetMetadata.mockReturnValue(null)
mockGetAssetType.mockReset()
})
describe('addWorkflow', () => {
@@ -275,7 +328,102 @@ describe('useMediaAssetActions', () => {
})
})
describe('downloadMultipleAssets - job_asset_name_filters', () => {
describe('downloadAssets', () => {
it('downloads the injected media asset when called without explicit assets', () => {
const mediaAsset = createMockMediaAsset({
id: 'context-asset',
name: 'context-name.png',
display_name: 'Context image.png',
preview_url: 'https://example.com/context-preview.png'
})
const { actions, unmount } = mountMediaActions(mediaAsset)
actions.downloadAssets()
expect(mockDownloadFile).toHaveBeenCalledOnce()
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/context-preview.png',
'Context image.png'
)
expect(mockCreateAssetExport).not.toHaveBeenCalled()
expect(mockTrackExport).not.toHaveBeenCalled()
unmount()
})
it('does nothing when called without explicit assets and no media context asset', () => {
const { actions, unmount } = mountMediaActions()
actions.downloadAssets()
expect(mockDownloadFile).not.toHaveBeenCalled()
expect(mockCreateAssetExport).not.toHaveBeenCalled()
expect(mockTrackExport).not.toHaveBeenCalled()
unmount()
})
it('keeps single explicit assets on the direct download path in cloud', () => {
mockIsCloud.value = true
mockGetOutputAssetMetadata.mockReturnValue({
jobId: 'job1',
outputCount: 1
})
const asset = createMockAsset({
id: 'single-output',
name: 'single-output.png',
preview_url: 'https://example.com/single-output.png',
tags: ['output'],
user_metadata: { jobId: 'job1', outputCount: 1 }
})
const actions = useMediaAssetActions()
actions.downloadAssets([asset])
expect(mockDownloadFile).toHaveBeenCalledOnce()
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/single-output.png',
'single-output.png'
)
expect(mockCreateAssetExport).not.toHaveBeenCalled()
expect(mockTrackExport).not.toHaveBeenCalled()
})
it('uses ZIP export for an injected single multi-output asset in cloud', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
mockGetOutputAssetMetadata.mockReturnValue({
jobId: 'job1',
outputCount: 3
})
const mediaAsset = createMockMediaAsset({
id: 'multi-output',
name: 'multi-output.png',
preview_url: 'https://example.com/multi-output.png',
tags: ['output'],
user_metadata: { jobId: 'job1', outputCount: 3 }
})
const { actions, unmount } = mountMediaActions(mediaAsset)
actions.downloadAssets()
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).not.toHaveBeenCalled()
expect(mockCreateAssetExport).toHaveBeenCalledWith({
job_ids: ['job1'],
naming_strategy: 'preserve'
})
expect(mockTrackExport).toHaveBeenCalledWith('test-task-id')
unmount()
})
})
describe('downloadAssets - cloud zip filters', () => {
beforeEach(() => {
mockIsCloud.value = true
mockCreateAssetExport.mockClear()
@@ -305,7 +453,7 @@ describe('useMediaAssetActions', () => {
const assets = [createOutputAsset('a1', 'img1.png', 'job1', 3)]
const actions = useMediaAssetActions()
actions.downloadMultipleAssets(assets)
actions.downloadAssets(assets)
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
@@ -323,7 +471,7 @@ describe('useMediaAssetActions', () => {
const j2 = createOutputAsset('a3', 'out2.png', 'job2', 1)
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1a, j1b, j2])
actions.downloadAssets([j1a, j1b, j2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
@@ -340,7 +488,7 @@ describe('useMediaAssetActions', () => {
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([asset1, asset2])
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
@@ -360,7 +508,7 @@ describe('useMediaAssetActions', () => {
const j2 = createOutputAsset('a3', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1a, j1b, j2])
actions.downloadAssets([j1a, j1b, j2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
@@ -379,7 +527,7 @@ describe('useMediaAssetActions', () => {
const asset2 = createOutputAsset('a2', 'img2.png', 'job1')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([asset1, asset2])
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)

View File

@@ -64,52 +64,30 @@ export function useMediaAssetActions() {
}
}
const downloadAsset = (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
try {
const filename = getAssetDisplayName(targetAsset)
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
downloadFile(downloadUrl, filename)
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', 1),
life: 2000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
}
/**
* Download multiple assets at once.
* In cloud mode with 2+ assets, creates a ZIP export via the backend.
* Falls back to individual downloads in OSS mode or for single assets.
* Download one or more assets.
* In cloud mode, creates a ZIP export via the backend when called with
* 2+ assets or with any asset whose job has `outputCount > 1`.
* Falls back to direct downloads in OSS mode and for single single-output
* assets. With no argument, uses the asset from `MediaAssetKey` context.
*/
const downloadMultipleAssets = (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
const downloadAssets = (assets?: AssetItem[]) => {
const targetAssets =
assets ?? (mediaContext?.asset.value ? [mediaContext.asset.value] : [])
if (targetAssets.length === 0) return
const hasMultiOutputJobs = assets.some((a) => {
const hasMultiOutputJobs = targetAssets.some((a) => {
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
return typeof count === 'number' && count > 1
})
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
void downloadMultipleAssetsAsZip(assets)
if (isCloud && (targetAssets.length > 1 || hasMultiOutputJobs)) {
void downloadAssetsAsZip(targetAssets)
return
}
try {
assets.forEach((asset) => {
targetAssets.forEach((asset) => {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
@@ -118,7 +96,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', assets.length),
detail: t('mediaAsset.selection.downloadsStarted', targetAssets.length),
life: 2000
})
} catch (error) {
@@ -131,7 +109,7 @@ export function useMediaAssetActions() {
}
}
async function downloadMultipleAssetsAsZip(assets: AssetItem[]) {
async function downloadAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()
try {
@@ -720,8 +698,7 @@ export function useMediaAssetActions() {
}
return {
downloadAsset,
downloadMultipleAssets,
downloadAssets,
deleteAssets,
copyJobId,
addWorkflow,

View File

@@ -92,6 +92,7 @@ function draw() {
// @ts-expect-error canvasHeight is a custom property used by some extensions
node.canvasHeight = height
widgetInstance.y = 0
widgetInstance.width = width
canvasEl.value.height = (height + 2) * scaleFactor
canvasEl.value.width = width * scaleFactor
const ctx = canvasEl.value?.getContext('2d')

View File

@@ -0,0 +1,331 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getFromAvifFile } from './avif'
const setU32BE = (dv: DataView, off: number, val: number) =>
dv.setUint32(off, val, false)
const setU16BE = (dv: DataView, off: number, val: number) =>
dv.setUint16(off, val, false)
const buildExifBlob = (
asciiEntries: string[],
endian: 'II' | 'MM' = 'II'
): Uint8Array => {
const isLE = endian === 'II'
const headerSize = 8
const ifdSize = 2 + asciiEntries.length * 12 + 4
const entryDataSizes = asciiEntries.map((s) => s.length + 1)
const entryDataTotal = entryDataSizes.reduce((a, b) => a + b, 0)
const buf = new Uint8Array(headerSize + ifdSize + entryDataTotal)
const dv = new DataView(buf.buffer)
buf[0] = endian === 'II' ? 0x49 : 0x4d
buf[1] = buf[0]
dv.setUint16(2, 0x002a, isLE)
dv.setUint32(4, 8, isLE)
let p = 8
dv.setUint16(p, asciiEntries.length, isLE)
p += 2
let dataOffset = headerSize + ifdSize
for (let i = 0; i < asciiEntries.length; i++) {
const dataLen = entryDataSizes[i]
const tag = 0x9286 + i
dv.setUint16(p, tag, isLE)
p += 2
dv.setUint16(p, 2, isLE)
p += 2
dv.setUint32(p, dataLen, isLE)
p += 4
dv.setUint32(p, dataOffset, isLE)
p += 4
const enc = new TextEncoder().encode(asciiEntries[i])
buf.set(enc, dataOffset)
buf[dataOffset + enc.length] = 0
dataOffset += dataLen
}
dv.setUint32(p, 0, isLE)
return buf
}
const buildInfeBox = (
itemId: number,
itemType: string,
version = 2
): Uint8Array => {
const bodySize = 4 + 2 + 2 + 4 + 1 + 1
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('infe'), 4)
buf[8] = version
if (version >= 2) {
setU16BE(dv, 12, itemId)
setU16BE(dv, 14, 0)
buf.set(new TextEncoder().encode(itemType.padEnd(4).slice(0, 4)), 16)
}
return buf
}
const buildIinfBox = (infeBoxes: Uint8Array[]): Uint8Array => {
const bodySize = 4 + 2 + infeBoxes.reduce((s, b) => s + b.length, 0)
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('iinf'), 4)
setU16BE(dv, 12, infeBoxes.length)
let off = 14
for (const ib of infeBoxes) {
buf.set(ib, off)
off += ib.length
}
return buf
}
const buildIlocBox = (
items: { itemId: number; extentOffset: number; extentLength: number }[]
): Uint8Array => {
const perItemSize = 2 + 2 + 0 + 2 + (4 + 4)
const bodySize = 4 + 1 + 1 + 2 + items.length * perItemSize
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('iloc'), 4)
buf[12] = 0x44
buf[13] = 0x00
setU16BE(dv, 14, items.length)
let p = 16
for (const it of items) {
setU16BE(dv, p, it.itemId)
p += 2
setU16BE(dv, p, 0)
p += 2
setU16BE(dv, p, 1)
p += 2
setU32BE(dv, p, it.extentOffset)
p += 4
setU32BE(dv, p, it.extentLength)
p += 4
}
return buf
}
const buildMetaBox = (boxes: Uint8Array[]): Uint8Array => {
const bodySize = 4 + boxes.reduce((s, b) => s + b.length, 0)
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('meta'), 4)
let p = 12
for (const b of boxes) {
buf.set(b, p)
p += b.length
}
return buf
}
const buildFtypBox = (majorBrand = 'avif'): Uint8Array => {
const buf = new Uint8Array(16)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, 16)
buf.set(new TextEncoder().encode('ftyp'), 4)
buf.set(new TextEncoder().encode(majorBrand.padEnd(4).slice(0, 4)), 8)
setU32BE(dv, 12, 0)
return buf
}
interface BuildAvifOpts {
exifEntries?: string[]
endian?: 'II' | 'MM'
itemType?: string
ftypBrand?: string
omitMeta?: boolean
omitIloc?: boolean
infeVersion?: number
}
const buildAvifFile = (opts: BuildAvifOpts = {}): ArrayBuffer => {
const {
exifEntries = [],
endian = 'II',
itemType = 'Exif',
ftypBrand = 'avif',
omitMeta = false,
omitIloc = false,
infeVersion = 2
} = opts
const ftyp = buildFtypBox(ftypBrand)
if (omitMeta) {
return ftyp.slice().buffer as ArrayBuffer
}
const exifData = buildExifBlob(exifEntries, endian)
const infe = buildInfeBox(1, itemType, infeVersion)
const iinf = buildIinfBox([infe])
const realIloc = buildIlocBox([
{ itemId: 1, extentOffset: 0, extentLength: exifData.length }
])
const metaSize = 8 + 4 + iinf.length + (omitIloc ? 0 : realIloc.length)
const exifOffset = ftyp.length + metaSize
const finalIloc = buildIlocBox([
{ itemId: 1, extentOffset: exifOffset, extentLength: exifData.length }
])
const finalInner = omitIloc ? [iinf] : [iinf, finalIloc]
const meta = buildMetaBox(finalInner)
const total = ftyp.length + meta.length + exifData.length
const buf = new Uint8Array(total)
let p = 0
buf.set(ftyp, p)
p += ftyp.length
buf.set(meta, p)
p += meta.length
buf.set(exifData, p)
return buf.slice().buffer as ArrayBuffer
}
const fileFromBuffer = (buffer: ArrayBuffer, name = 'test.avif'): File =>
new File([buffer], name, { type: 'image/avif' })
describe('getFromAvifFile', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
vi.spyOn(console, 'log').mockImplementation(() => undefined)
})
it('extracts workflow JSON from EXIF when AVIF has an Exif item', async () => {
const workflow = '{"nodes":[],"version":1}'
const file = fileFromBuffer(
buildAvifFile({ exifEntries: [`workflow:${workflow}`] })
)
const result = await getFromAvifFile(file)
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
})
it('extracts prompt JSON from EXIF', async () => {
const prompt = '{"1":{"class_type":"KSampler"}}'
const file = fileFromBuffer(
buildAvifFile({ exifEntries: [`prompt:${prompt}`] })
)
const result = await getFromAvifFile(file)
expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt)))
})
it('parses big-endian (MM) EXIF data', async () => {
const workflow = '{"endian":"big"}'
const file = fileFromBuffer(
buildAvifFile({ exifEntries: [`workflow:${workflow}`], endian: 'MM' })
)
const result = await getFromAvifFile(file)
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
})
it('returns {} when AVIF major brand is not "avif"', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{}'], ftypBrand: 'heic' })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when meta box is missing', async () => {
const file = fileFromBuffer(buildAvifFile({ omitMeta: true }))
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when iinf has no Exif item', async () => {
const file = fileFromBuffer(
buildAvifFile({
exifEntries: ['workflow:{}'],
itemType: 'mime'
})
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when EXIF entry uses an unrecognized key', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['random:thing'] })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when EXIF entry has malformed JSON', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{notjson'] })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} (and does not throw) when infe version is unsupported', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{}'], infeVersion: 1 })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when iloc box is missing while iinf has an Exif item', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{}'], omitIloc: true })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when buffer is too short to contain a valid header', async () => {
const file = fileFromBuffer(new Uint8Array(4).buffer)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('extracts both prompt and workflow when present in separate EXIF entries', async () => {
const prompt = '{"node":1}'
const workflow = '{"nodes":[1]}'
const file = fileFromBuffer(
buildAvifFile({
exifEntries: [`prompt:${prompt}`, `workflow:${workflow}`]
})
)
const result = await getFromAvifFile(file)
expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt)))
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
})
})

View File

@@ -1,6 +1,25 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { getWebpMetadata } from './pnginfo'
import { getFromAvifFile } from './metadata/avif'
import { getFromFlacFile } from './metadata/flac'
import { getFromPngFile } from './metadata/png'
import {
getAvifMetadata,
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
getWebpMetadata
} from './pnginfo'
vi.mock('./metadata/png', () => ({
getFromPngFile: vi.fn()
}))
vi.mock('./metadata/flac', () => ({
getFromFlacFile: vi.fn()
}))
vi.mock('./metadata/avif', () => ({
getFromAvifFile: vi.fn()
}))
function buildExifPayload(workflowJson: string): Uint8Array {
const fullStr = `workflow:${workflowJson}\0`
@@ -65,3 +84,69 @@ describe('getWebpMetadata', () => {
expect(metadata.workflow).toBe(workflow)
})
})
describe('format-specific metadata wrappers', () => {
it('getPngMetadata delegates to getFromPngFile', async () => {
const file = new File([], 'a.png', { type: 'image/png' })
vi.mocked(getFromPngFile).mockResolvedValue({ workflow: '{"png":1}' })
const result = await getPngMetadata(file)
expect(getFromPngFile).toHaveBeenCalledWith(file)
expect(result).toEqual({ workflow: '{"png":1}' })
})
it('getFlacMetadata delegates to getFromFlacFile', async () => {
const file = new File([], 'a.flac', { type: 'audio/flac' })
vi.mocked(getFromFlacFile).mockResolvedValue({ workflow: '{"flac":1}' })
const result = await getFlacMetadata(file)
expect(getFromFlacFile).toHaveBeenCalledWith(file)
expect(result).toEqual({ workflow: '{"flac":1}' })
})
it('getAvifMetadata delegates to getFromAvifFile', async () => {
const file = new File([], 'a.avif', { type: 'image/avif' })
vi.mocked(getFromAvifFile).mockResolvedValue({ workflow: '{"avif":1}' })
const result = await getAvifMetadata(file)
expect(getFromAvifFile).toHaveBeenCalledWith(file)
expect(result).toEqual({ workflow: '{"avif":1}' })
})
})
const buildSafetensors = (header: Record<string, unknown>): File => {
const headerJson = JSON.stringify(header)
const headerBytes = new TextEncoder().encode(headerJson)
const buf = new ArrayBuffer(8 + headerBytes.length)
const dv = new DataView(buf)
dv.setUint32(0, headerBytes.length, true)
dv.setUint32(4, 0, true)
new Uint8Array(buf, 8).set(headerBytes)
return new File([buf], 'x.safetensors')
}
describe('getLatentMetadata', () => {
it('returns the __metadata__ object from a safetensors header', async () => {
const file = buildSafetensors({
__metadata__: { workflow: '{"nodes":[]}', extra: 'value' },
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
})
const result = await getLatentMetadata(file)
expect(result).toEqual({ workflow: '{"nodes":[]}', extra: 'value' })
})
it('resolves undefined when header has no __metadata__ entry', async () => {
const file = buildSafetensors({
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
})
const result = await getLatentMetadata(file)
expect(result).toBeUndefined()
})
})

View File

@@ -0,0 +1,743 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLoad3dService } from '@/services/load3dService'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const { nodeMap, useLoad3dViewerMock, skeletonCloneMock } = vi.hoisted(() => ({
nodeMap: new Map<LGraphNode, Load3d>(),
useLoad3dViewerMock: vi.fn(),
skeletonCloneMock: vi.fn()
}))
vi.mock('@/composables/useLoad3d', () => ({
nodeToLoad3dMap: nodeMap
}))
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: useLoad3dViewerMock
}))
vi.mock('three/examples/jsm/utils/SkeletonUtils', () => ({
clone: skeletonCloneMock
}))
// Track every node a test creates so the load3dService singleton's
// internal viewerInstances map can be drained in beforeEach without
// reaching into the module's private state.
const createdNodes = new Set<LGraphNode>()
function makeNode(id: number | string): LGraphNode {
const node = createMockLGraphNode({ id })
createdNodes.add(node)
return node
}
function makeLoad3d(): Load3d {
return {
remove: vi.fn()
} as unknown as Load3d
}
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
needApplyChanges: { value: false },
applyChanges: vi.fn().mockResolvedValue(true),
cleanup: vi.fn(),
...overrides
}
}
describe('load3dService', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeMap.clear()
const svc = useLoad3dService()
for (const node of createdNodes) svc.removeViewer(node)
createdNodes.clear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('singleton', () => {
it('returns the same instance from useLoad3dService()', () => {
expect(useLoad3dService()).toBe(useLoad3dService())
})
})
describe('getLoad3d (sync)', () => {
it('returns null when the load3d module has not been loaded yet', () => {
// Before any async accessor has been called, the cache is empty.
// We can't easily simulate "module never loaded" because vi.mock makes
// it eagerly available, so this test verifies the behavior via missing
// entries instead.
const node = makeNode('missing')
expect(useLoad3dService().getLoad3d(node)).toBeNull()
})
it('returns null after async load when the node has no entry in the map', async () => {
const svc = useLoad3dService()
// Trigger the async loader so the sync path has a populated cache.
await svc.getLoad3dAsync(makeNode('anything'))
expect(svc.getLoad3d(makeNode('still-missing'))).toBeNull()
})
it('returns the registered Load3d instance once the map has been populated', async () => {
const svc = useLoad3dService()
const node = makeNode('a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getLoad3d(node)).toBe(load3d)
})
})
describe('getLoad3dAsync', () => {
it('returns the Load3d for a registered node', async () => {
const svc = useLoad3dService()
const node = makeNode('async-a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await expect(svc.getLoad3dAsync(node)).resolves.toBe(load3d)
})
it('returns null for an unregistered node', async () => {
const svc = useLoad3dService()
await expect(
svc.getLoad3dAsync(makeNode('async-missing'))
).resolves.toBeNull()
})
})
describe('getNodeByLoad3d', () => {
it('finds the node owning a given Load3d instance', async () => {
const svc = useLoad3dService()
const node = makeNode('owner')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getNodeByLoad3d(load3d)).toBe(node)
})
it('returns null when the Load3d instance is not in the map', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(svc.getNodeByLoad3d(makeLoad3d())).toBeNull()
})
})
describe('removeLoad3d', () => {
it('calls remove() on the instance and drops it from the map', async () => {
const svc = useLoad3dService()
const node = makeNode('to-remove')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
svc.removeLoad3d(node)
expect(load3d.remove).toHaveBeenCalled()
expect(nodeMap.has(node)).toBe(false)
})
it('is a no-op when the node has no registered Load3d', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(() => svc.removeLoad3d(makeNode('not-there'))).not.toThrow()
})
})
describe('clear', () => {
it('removes every registered Load3d', async () => {
const svc = useLoad3dService()
const a = makeNode('a')
const b = makeNode('b')
const ld1 = makeLoad3d()
const ld2 = makeLoad3d()
nodeMap.set(a, ld1)
nodeMap.set(b, ld2)
await svc.getLoad3dAsync(a)
svc.clear()
expect(nodeMap.size).toBe(0)
expect(ld1.remove).toHaveBeenCalled()
expect(ld2.remove).toHaveBeenCalled()
})
})
describe('viewer lifecycle', () => {
it('getOrCreateViewer creates a viewer on first call and reuses it on subsequent calls', async () => {
const svc = useLoad3dService()
const node = makeNode('v1')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
const first = await svc.getOrCreateViewer(node)
const second = await svc.getOrCreateViewer(node)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(useLoad3dViewerMock).toHaveBeenCalledWith(node)
})
it('getOrCreateViewerSync uses the supplied factory once and caches the result', () => {
const svc = useLoad3dService()
const node = makeNode('v-sync')
const viewer = makeViewer()
const factory = vi.fn().mockReturnValue(viewer)
const first = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
const second = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(factory).toHaveBeenCalledTimes(1)
})
it('removeViewer calls cleanup and forgets the viewer', async () => {
const svc = useLoad3dService()
const node = makeNode('v2')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
svc.removeViewer(node)
expect(viewer.cleanup).toHaveBeenCalled()
useLoad3dViewerMock.mockClear()
const fresh = makeViewer()
useLoad3dViewerMock.mockReturnValue(fresh)
const result = await svc.getOrCreateViewer(node)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(result).toBe(fresh)
})
it('removeViewer is safe when no viewer has been created for the node', () => {
const svc = useLoad3dService()
expect(() => svc.removeViewer(makeNode('never'))).not.toThrow()
})
})
describe('handleViewerClose', () => {
it('removes the viewer without applying changes when none are pending', async () => {
const svc = useLoad3dService()
const node = makeNode('close-clean')
const viewer = makeViewer({ needApplyChanges: { value: false } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).not.toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('applies changes and syncs the node config when changes are pending', async () => {
const svc = useLoad3dService()
const syncLoad3dConfig = vi.fn()
const node = Object.assign(makeNode('close-dirty'), {
syncLoad3dConfig
}) as LGraphNode
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).toHaveBeenCalled()
expect(syncLoad3dConfig).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('skips syncLoad3dConfig when the node does not define it', async () => {
const svc = useLoad3dService()
const node = makeNode('close-no-sync')
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await expect(svc.handleViewerClose(node)).resolves.toBeUndefined()
expect(viewer.applyChanges).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
})
describe('handleViewportRefresh', () => {
it('returns silently when the load3d is null', () => {
expect(() => useLoad3dService().handleViewportRefresh(null)).not.toThrow()
})
it('toggles the camera through the opposite type and back, then updates controls', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.handleResize).toHaveBeenCalled()
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'orthographic')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'perspective')
expect(controls.update).toHaveBeenCalled()
})
it('toggles in the reverse direction when starting from orthographic', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('orthographic'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'perspective')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'orthographic')
})
})
describe('copyLoad3dState', () => {
type SourceOverrides = Partial<{
currentModel: THREE.Object3D | null
isSplat: boolean
originalURL: string | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
gizmoEnabled: boolean
hasAnimations: boolean
cameraType: 'perspective' | 'orthographic'
backgroundInfo: { type: 'image' | 'color' }
lightsIntensity: number | undefined
fov: number
}>
function makeSource(overrides: SourceOverrides = {}): Load3d {
const {
currentModel = null,
isSplat = false,
originalURL = null,
originalModel = null,
materialMode = 'original',
currentUpDirection = 'original',
appliedTexture = null,
gizmoEnabled = false,
hasAnimations = false,
cameraType = 'perspective',
backgroundInfo = { type: 'color' },
lightsIntensity = 0.8,
fov = 35
} = overrides
const ambient = { intensity: 0.5 }
const main = { intensity: lightsIntensity }
return {
modelManager: { currentModel, originalURL },
getGizmoManager: () => ({
isEnabled: () => gizmoEnabled,
getInitialTransform: () => ({
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 4, y: 5, z: 6 }
})
}),
isSplatModel: () => isSplat,
getModelManager: () => ({
originalModel,
materialMode,
currentUpDirection,
appliedTexture
}),
getGizmoTransform: () => ({
position: { x: 7, y: 8, z: 9 },
rotation: { x: 0.4, y: 0.5, z: 0.6 },
scale: { x: 10, y: 11, z: 12 }
}),
hasAnimations: () => hasAnimations,
getCurrentCameraType: () => cameraType,
getCameraState: () => ({ snapshot: true }),
getSceneManager: () => ({
scene: new THREE.Scene(),
currentBackgroundColor: '#abcdef',
gridHelper: { visible: true },
getCurrentBackgroundInfo: () => backgroundInfo
}),
getLightingManager: () => ({ lights: [ambient, main] }),
getCameraManager: () => ({ perspectiveCamera: { fov } })
} as unknown as Load3d
}
type TargetState = {
modelManager: {
currentModel: THREE.Object3D | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
}
gizmoManager: {
isEnabled: () => boolean
detach: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
}
animationManager: {
setupModelAnimations: ReturnType<typeof vi.fn>
}
sceneRemoved: THREE.Object3D[]
sceneAdded: THREE.Object3D[]
}
function makeTarget(
opts: {
gizmoEnabled?: boolean
existingModel?: THREE.Object3D | null
} = {}
) {
const { gizmoEnabled = false, existingModel = null } = opts
const scene = new THREE.Scene()
const sceneRemoved: THREE.Object3D[] = []
const sceneAdded: THREE.Object3D[] = []
const sceneRemove = vi.fn((o: THREE.Object3D) => {
sceneRemoved.push(o)
scene.remove(o)
})
const sceneAdd = vi.fn((o: THREE.Object3D) => {
sceneAdded.push(o)
scene.add(o)
})
const modelManager = {
currentModel: existingModel as THREE.Object3D | null,
originalModel: null as unknown,
materialMode: 'original',
currentUpDirection: 'original',
appliedTexture: null as unknown
}
const animationManager = {
setupModelAnimations: vi.fn()
}
// Memoize the gizmo manager so production code's repeated
// `target.getGizmoManager()` calls reach the same vi.fn instances.
const gizmoManager = {
isEnabled: () => gizmoEnabled,
detach: vi.fn(),
setupForModel: vi.fn()
}
const target = {
getGizmoManager: () => gizmoManager,
getModelManager: () => modelManager,
getSceneManager: () => ({
scene: {
add: sceneAdd,
remove: sceneRemove
} as unknown as THREE.Scene
}),
loadModel: vi.fn().mockResolvedValue(undefined),
setMaterialMode: vi.fn(),
setUpDirection: vi.fn(),
applyGizmoTransform: vi.fn(),
setGizmoEnabled: vi.fn(),
animationManager,
toggleCamera: vi.fn(),
setCameraState: vi.fn(),
setBackgroundColor: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setLightIntensity: vi.fn(),
setFOV: vi.fn()
} as unknown as Load3d
const state: TargetState = {
modelManager,
gizmoManager,
animationManager,
sceneRemoved,
sceneAdded
}
return { target, state }
}
function makeModel(): THREE.Object3D {
return new THREE.Object3D()
}
it('copies camera/scene/lighting/FOV even when there is no source model', async () => {
const source = makeSource({ currentModel: null, lightsIntensity: 2 })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.toggleCamera).toHaveBeenCalledWith('perspective')
expect(target.setCameraState).toHaveBeenCalledWith({ snapshot: true })
expect(target.setBackgroundColor).toHaveBeenCalledWith('#abcdef')
expect(target.toggleGrid).toHaveBeenCalledWith(true)
expect(target.setLightIntensity).toHaveBeenCalledWith(2)
expect(target.setFOV).toHaveBeenCalledWith(35)
expect(skeletonCloneMock).not.toHaveBeenCalled()
expect(target.loadModel).not.toHaveBeenCalled()
})
it('uses target.loadModel(originalURL) for splat models, never invoking SkeletonUtils.clone', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: 'http://example.com/scan.splat'
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).toHaveBeenCalledWith(
'http://example.com/scan.splat'
)
expect(skeletonCloneMock).not.toHaveBeenCalled()
})
it('skips loadModel for splat models when originalURL is null', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: null
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).not.toHaveBeenCalled()
})
it('removes the target existing model from the scene before adding the clone', async () => {
const existing = makeModel()
existing.name = 'existing'
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget({ existingModel: existing })
const clone = makeModel()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.sceneRemoved).toContain(existing)
expect(state.sceneAdded).toContain(clone)
})
it('clones the source model via SkeletonUtils and assigns it as the target current model', async () => {
const sourceModel = makeModel()
const clone = makeModel()
const source = makeSource({ currentModel: sourceModel })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(skeletonCloneMock).toHaveBeenCalledWith(sourceModel)
expect(state.modelManager.currentModel).toBe(clone)
})
it('copies originalModel, material mode, up direction, and applied texture from source to target', async () => {
const sourceOriginal = { kind: 'gltf' }
const texture = { id: 'tex1' }
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
materialMode: 'wireframe',
currentUpDirection: '+y',
appliedTexture: texture
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.modelManager.originalModel).toBe(sourceOriginal)
expect(state.modelManager.materialMode).toBe('wireframe')
expect(state.modelManager.currentUpDirection).toBe('+y')
expect(state.modelManager.appliedTexture).toBe(texture)
expect(target.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(target.setUpDirection).toHaveBeenCalledWith('+y')
})
it('positions the clone at the source initial transform', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(clone.position.toArray()).toEqual([1, 2, 3])
expect(clone.rotation.toArray().slice(0, 3)).toEqual([0.1, 0.2, 0.3])
expect(clone.scale.toArray()).toEqual([4, 5, 6])
})
it('applies the source gizmo transform to the target', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.applyGizmoTransform).toHaveBeenCalledWith(
{ x: 7, y: 8, z: 9 },
{ x: 0.4, y: 0.5, z: 0.6 },
{ x: 10, y: 11, z: 12 }
)
})
it('enables the gizmo on target when the source had it enabled', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: true
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('enables the gizmo on target when the target previously had it enabled, even if source did not', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: true })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('does not enable the gizmo when neither side had it', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).not.toHaveBeenCalled()
})
it('forwards animation setup when the source has animations', async () => {
const sourceOriginal = { kind: 'gltf' }
const clone = makeModel()
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
hasAnimations: true
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).toHaveBeenCalledWith(
clone,
sourceOriginal
)
})
it('does not forward animation setup when the source has none', async () => {
const source = makeSource({
currentModel: makeModel(),
hasAnimations: false
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).not.toHaveBeenCalled()
})
it('forwards an image background to setBackgroundImage when the source node has a configured path', async () => {
const node = createMockLGraphNode({
id: 'bg-source',
properties: { 'Scene Config': { backgroundImage: '3d/bg.png' } }
})
createdNodes.add(node)
const source = makeSource({ backgroundInfo: { type: 'image' } })
nodeMap.set(node, source)
// Warm the cache so `getNodeByLoad3d` finds the source.
await useLoad3dService().getLoad3dAsync(node)
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('3d/bg.png')
})
it('clears the background when the source background type is not image', async () => {
const source = makeSource({ backgroundInfo: { type: 'color' } })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('')
})
it('falls back to setLightIntensity(1) when the second light intensity is falsy', async () => {
const source = makeSource({ lightsIntensity: 0 })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setLightIntensity).toHaveBeenCalledWith(1)
})
it('skips setFOV when the source camera is orthographic', async () => {
const source = makeSource({ cameraType: 'orthographic' })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setFOV).not.toHaveBeenCalled()
})
it('always detaches the target gizmo at the start of the copy', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.detach).toHaveBeenCalled()
})
it('calls setupForModel on the target gizmo with the freshly cloned model', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.setupForModel).toHaveBeenCalledWith(clone)
})
})
})

View File

@@ -754,3 +754,301 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
expect(store.missingNodesError?.nodeTypes).toEqual(input)
})
})
describe('useExecutionStore - WebSocket event handlers', () => {
let store: ReturnType<typeof useExecutionStore>
function fire<T>(event: string, detail: T) {
const handler = apiEventHandlers.get(event)
if (!handler) throw new Error(`${event} handler not bound`)
handler(new CustomEvent(event, { detail }))
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
describe('execution_start', () => {
it('sets activeJobId and seeds an empty queued job entry', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
expect(store.activeJobId).toBe('job-1')
expect(store.queuedJobs['job-1']).toEqual({ nodes: {} })
})
it('clears initializing state for the starting job', () => {
store.initializingJobIds = new Set([
'job-1',
'job-2'
]) as unknown as Set<string>
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
expect(store.initializingJobIds.has('job-1')).toBe(false)
expect(store.initializingJobIds.has('job-2')).toBe(true)
})
})
describe('execution_cached', () => {
it('marks the listed nodes as cached on the active job', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
fire('execution_cached', {
prompt_id: 'job-1',
nodes: ['nodeA', 'nodeB'],
timestamp: 0
})
expect(store.activeJob?.nodes).toEqual({ nodeA: true, nodeB: true })
})
it('is a no-op when no active job exists', () => {
fire('execution_cached', {
prompt_id: 'job-1',
nodes: ['nodeA'],
timestamp: 0
})
expect(store.activeJob).toBeUndefined()
})
})
describe('execution_interrupted', () => {
it('clears active job state on interrupt', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
expect(store.activeJobId).toBe('job-1')
fire('execution_interrupted', {
prompt_id: 'job-1',
node_id: 'n1',
node_type: 't',
executed: [],
timestamp: 0
})
expect(store.activeJobId).toBeNull()
expect(store.queuedJobs['job-1']).toBeUndefined()
})
})
describe('executed', () => {
it('marks the executed node as done on the active job', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
fire('execution_cached', {
prompt_id: 'job-1',
nodes: ['n1'],
timestamp: 0
})
fire('executed', {
node: 'n1',
display_node: 'n1',
prompt_id: 'job-1',
output: {}
})
expect(store.activeJob?.nodes['n1']).toBe(true)
})
it('is a no-op when no active job exists', () => {
expect(() =>
fire('executed', {
node: 'n1',
display_node: 'n1',
prompt_id: 'orphan',
output: {}
})
).not.toThrow()
expect(store.activeJob).toBeUndefined()
})
})
describe('execution_success', () => {
it('clears active job and progress state', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(store.activeJobId).toBeNull()
expect(store.queuedJobs['job-1']).toBeUndefined()
})
})
describe('executing', () => {
it('clears _executingNodeProgress and activeJobId when detail is null', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
store._executingNodeProgress = {
value: 1,
max: 2,
prompt_id: 'job-1',
node: '1'
}
fire('executing', null)
expect(store._executingNodeProgress).toBeNull()
expect(store.activeJobId).toBeNull()
})
})
describe('progress', () => {
it('sets _executingNodeProgress from the event payload', () => {
const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }
fire('progress', payload)
expect(store._executingNodeProgress).toEqual(payload)
})
})
describe('status', () => {
it('reads clientId from api once and stops listening', async () => {
const apiModule = await import('@/scripts/api')
const removeSpy = vi.mocked(apiModule.api.removeEventListener)
fire('status', { exec_info: { queue_remaining: 0 } })
expect(store.clientId).toBe('test-client')
expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function))
})
})
describe('execution_error', () => {
it('routes a service-level error (no node_id) to the prompt error store', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: null,
exception_type: 'StagnationError',
exception_message: 'Job has stagnated',
traceback: ['line 1', 'line 2']
})
expect(errorStore.lastPromptError).toMatchObject({
type: 'StagnationError',
message: 'StagnationError: Job has stagnated',
details: 'line 1\nline 2'
})
})
it('routes a runtime error (with node_id) to lastExecutionError', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: 'n1',
node_type: 'KSampler',
exception_type: 'RuntimeError',
exception_message: 'CUDA OOM',
traceback: []
})
expect(errorStore.lastExecutionError).toMatchObject({
prompt_id: 'job-1',
node_id: 'n1',
exception_message: 'CUDA OOM'
})
})
})
describe('notification', () => {
it('marks a job as initializing when text indicates waiting for a machine', () => {
fire('notification', {
id: 'job-9',
value: 'Waiting for a machine to become available'
})
expect(store.initializingJobIds.has('job-9')).toBe(true)
})
it('ignores notifications without an id', () => {
fire('notification', {
id: '',
value: 'Waiting for a machine'
})
expect(store.initializingJobIds.size).toBe(0)
})
it('ignores notifications without the waiting-for-machine sentinel', () => {
fire('notification', { id: 'job-9', value: 'Hello' })
expect(store.initializingJobIds.has('job-9')).toBe(false)
})
})
describe('unbindExecutionEvents', () => {
it('removes every listener registered by bindExecutionEvents', async () => {
const apiModule = await import('@/scripts/api')
const removeSpy = vi.mocked(apiModule.api.removeEventListener)
const events = [
'notification',
'execution_start',
'execution_cached',
'execution_interrupted',
'execution_success',
'executed',
'executing',
'progress',
'progress_state',
'execution_error',
'progress_text'
]
store.unbindExecutionEvents()
for (const event of events) {
expect(removeSpy).toHaveBeenCalledWith(event, expect.any(Function))
}
})
})
})
describe('useExecutionStore - storeJob and workflow path tracking', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('storeJob populates queuedJobs and tracks the workflow path', () => {
const workflow = {
activeState: { id: 'wf-1' },
initialState: { id: 'wf-1' },
path: '/workflows/foo.json'
} as unknown as Parameters<typeof store.storeJob>[0]['workflow']
store.storeJob({ nodes: ['a', 'b'], id: 'job-1', workflow })
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false, b: false })
expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
'/workflows/foo.json'
)
})
it('registerJobWorkflowIdMapping ignores empty inputs', () => {
store.registerJobWorkflowIdMapping('job-1', 'wf-1')
store.registerJobWorkflowIdMapping('', 'wf-2')
store.registerJobWorkflowIdMapping('job-2', '')
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
expect(store.jobIdToWorkflowId.size).toBe(1)
})
it('ensureSessionWorkflowPath is idempotent and updates on change', () => {
store.ensureSessionWorkflowPath('job-1', '/a.json')
store.ensureSessionWorkflowPath('job-1', '/a.json')
store.ensureSessionWorkflowPath('job-1', '/b.json')
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { isStaleChunkError, parsePreloadError } from './preloadErrorUtil'
import { parsePreloadError } from './preloadErrorUtil'
describe('parsePreloadError', () => {
it('parses CSS preload error', () => {
@@ -90,74 +90,3 @@ describe('parsePreloadError', () => {
expect(result.chunkName).toBe('index')
})
})
describe('isStaleChunkError', () => {
it('returns true for hashed JS chunk under /assets/', () => {
const info = parsePreloadError(
new Error(
'Failed to fetch dynamically imported module: /assets/vendor-vue-core-abc123.js'
)
)
expect(isStaleChunkError(info)).toBe(true)
})
it('returns true for hashed CSS chunk under /assets/', () => {
const info = parsePreloadError(
new Error('Unable to preload CSS for /assets/style-9f8e7d.css')
)
expect(isStaleChunkError(info)).toBe(true)
})
it('returns true for hashed mjs chunk under /assets/', () => {
const info = parsePreloadError(
new Error(
'Failed to fetch dynamically imported module: /assets/chunk-abc123.mjs'
)
)
expect(isStaleChunkError(info)).toBe(true)
})
it('returns false for non-asset URLs like /api/i18n', () => {
const info = parsePreloadError(
new Error(
'Failed to fetch dynamically imported module: https://cloud.comfy.org/api/i18n'
)
)
expect(isStaleChunkError(info)).toBe(false)
})
it('returns false for unhashed asset files', () => {
const info = parsePreloadError(
new Error('Failed to fetch dynamically imported module: /assets/index.js')
)
expect(isStaleChunkError(info)).toBe(false)
})
it('returns false when no URL can be extracted', () => {
const info = parsePreloadError(new Error('Something failed'))
expect(isStaleChunkError(info)).toBe(false)
})
it('returns false for font files', () => {
const info = parsePreloadError(
new Error('Unable to preload CSS for /assets/inter-abc123.woff2')
)
expect(isStaleChunkError(info)).toBe(false)
})
it('returns false for image files', () => {
const info = parsePreloadError(
new Error('Unable to preload CSS for /assets/logo-abc123.png')
)
expect(isStaleChunkError(info)).toBe(false)
})
it('returns true for full URL with hashed asset path', () => {
const info = parsePreloadError(
new Error(
'Failed to fetch dynamically imported module: https://cloud.comfy.org/assets/vendor-three-def456.js'
)
)
expect(isStaleChunkError(info)).toBe(true)
})
})

View File

@@ -64,22 +64,6 @@ function extractChunkName(url: string): string | null {
return withoutHash || null
}
const HASHED_ASSET_RE = /\/assets\/.+-[a-f0-9]{6,}\.(js|mjs|css)$/
/**
* Determines if a preload error is a genuine stale chunk error — i.e. a hashed
* JS/CSS asset under /assets/ that 404'd, typically after a new deployment
* changed chunk hashes. Returns false for non-asset URLs (e.g. /api/i18n),
* unknown file types, and errors with no extractable URL.
*/
export function isStaleChunkError(info: PreloadErrorInfo): boolean {
if (!info.url) return false
if (info.fileType !== 'js' && info.fileType !== 'css') return false
const pathname = new URL(info.url, 'https://cloud.comfy.org').pathname
return HASHED_ASSET_RE.test(pathname)
}
export function parsePreloadError(error: Error): PreloadErrorInfo {
const message = error.message || String(error)
const url = extractUrl(message)

View File

@@ -113,4 +113,5 @@ async def delete_file(request: Request):
return web.Response(status=500, text=f"Error: {str(e)}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]

View File

@@ -302,6 +302,21 @@ class NodeWithV2ComboInput:
def node_with_v2_combo_input(self, combo_input: str):
return (combo_input,)
class NodeWithLegacyWidget:
@classmethod
def INPUT_TYPES(cls):
return {
"required": { "legacy_widget": ("INT", { "widgetType": "DEVTOOLSLEGACYWIDGET" }) }
}
RETURN_TYPES = ()
FUNCTION = "node_with_legacy_widget"
CATEGORY = "DevTools"
DESCRIPTION = ("A node with a legacy widget")
def node_with_legacy_widget(self):
return ()
NODE_CLASS_MAPPINGS = {
"DevToolsLongComboDropdown": LongComboDropdown,
@@ -318,6 +333,7 @@ NODE_CLASS_MAPPINGS = {
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
"DevToolsNodeWithValidation": NodeWithValidation,
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
"DevToolsNodeWithLegacyWidget": NodeWithLegacyWidget,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -335,6 +351,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"DevToolsNodeWithSeedInput": "Node With Seed Input",
"DevToolsNodeWithValidation": "Node With Validation",
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
"DevToolsNodeWithLegacyWidget": "Node With Legacy Widget",
}
__all__ = [

View File

@@ -0,0 +1,35 @@
//es
// eslint-disable-next-line import-x/no-unresolved -- import is correct at time of test execution
import { app } from '../../scripts/app.js'
function legacyWidget(node, inputName, inputData) {
if (!node.widgets) node.widgets = []
node.widgets.push({
draw: function (ctx, node, widget_width, y, H) {
ctx.save()
ctx.fillStyle = '#7F7'
ctx.fillRect(15, y, widget_width - 15 * 2, H)
ctx.restore()
},
mouse: function mouseAnnotated(event, [x, y], node) {
const widget_width = this.width || node.size[0]
if (x < 30) {
this.value--
} else if (x > widget_width - 30 && x < widget_width) {
this.value++
}
},
name: inputName,
options: {},
type: 'DEVTOOLS.LEGACYWIDGET',
value: 0,
y: 0
})
}
app.registerExtension({
name: 'DevTools.LegacyWidget',
async getCustomWidgets() {
return { DEVTOOLSLEGACYWIDGET: legacyWidget }
}
})