Compare commits

..

38 Commits

Author SHA1 Message Date
dante01yoon
75860fc322 test: add E2E tests for node template management 2026-04-27 14:15:21 +09:00
Dante
c594e30b84 test: harden useKeyboard test setup with vi.hoisted and try/finally (#11659)
## Summary
- Move `mockCanvasHistory` / `mockStore` into `vi.hoisted()` so the mock
state is hoisted before module imports, matching the pattern in
`useCanvasTransform.test.ts`.
- Wrap the temporary `document.activeElement` override in `try/finally`
so the property is restored even if the assertion throws, preventing
state leak into subsequent tests.

- Fixes #11658

## Test plan
- [x] `pnpm test:unit src/composables/maskeditor/useKeyboard.test.ts` —
17/17 pass
- [x] `pnpm typecheck`
- [x] `pnpm lint` (no new warnings)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11659-test-harden-useKeyboard-test-setup-with-vi-hoisted-and-try-finally-34f6d73d36508139be2ddc3095ea6952)
by [Unito](https://www.unito.io)
2026-04-27 03:26:33 +00:00
Dante
2ade779a81 fix: use getAssetFilename in asset browser to avoid showing hashes (#11492)
## Root cause
Three surfaces rendered `asset.name` directly even though in Cloud
production `asset.name` often equals `asset.asset_hash`:

1. **Asset browser modal** (`AssetCard.vue`) used `getAssetDisplayName`,
which — when `user_metadata.name` also held the hash — fell through to
the raw hash.
2. **Load Image node widget dropdown** (`useWidgetSelectItems.ts`)
rendered output items as `${asset.name} [output]`.
3. Queue-mapped output assets (`mapTaskOutputToAssetItem`) populate the
human-readable filename only on `asset.display_name`, not on
`user_metadata.filename` / `metadata.filename`. So surfaces that rely
only on `getAssetFilename` still fall through to `asset.name` (the
hash).

Filename / title resolution is now split into three helpers with
distinct responsibilities:

- `getAssetFilename` (unchanged) — canonical filename for serialization
/ identifier use (workflow widget values, schema validation,
missing-model matching). Never substitutes a display-only string.
- `getAssetDisplayFilename` (new) — filename-first label for surfaces
that render a filename. Adds `asset.display_name` as a fallback before
`asset.name`. Used by the Load Image output dropdown.
- `getAssetCardTitle` (new) — card title / delete-dialog label. Prefers
`user_metadata.name` / `metadata.name` when distinct from `asset.name`
(preserves user-renamed model titles from `ModelInfoPanel`), and falls
through to `getAssetDisplayFilename` for the Cloud hash case.

The dropdown item's `name` field (workflow payload value) is still
`${asset.name} [output]`, so Cloud can continue to resolve the asset by
hash.

Fixes FE-228

Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229

## Red / green verification
| Step | SHA | Purpose |
| --- | --- | --- |
| Red (card — metadata.filename) | `20b32e4f0` | `AssetCard.test.ts`
asserts the rendered title is the human-readable filename when
`asset.name === asset_hash`. |
| Green (card) | `ea889b34c` | `AssetCard.vue` switches from
`getAssetDisplayName` to `getAssetFilename`. |
| Red (widget — metadata.filename) | `318feddec` | Asserts the output
dropdown `label` uses `metadata.filename` when `asset.name` is a hash. |
| Green (widget) | `7b19bde15` | `useWidgetSelectItems.ts` derives
`label` from `getAssetFilename`; `name` (serialized) stays
`\${asset.name} [output]`. |
| Red (widget — display_name path) | `b19716e60` | Failing test for the
queue-mapped shape (`display_name` populated, `user_metadata.filename`
absent). |
| Green (util, reverted) | `533e60d6a` | Initial attempt broadened
`getAssetFilename` to include `display_name`; altered filename semantics
for model-asset consumers. |
| Refactor (scope narrowing) | `7c1085f30` | Reverts the util change;
applies `display_name` fallback locally in the output dropdown only. |
| Red (card — display_name path) | `38a9d4828` | Failing test: AssetCard
must fall back to `display_name` when filename metadata is absent. |
| Green (helper split) | `4ca0f620f` | Introduces
`getAssetDisplayFilename`; swaps AssetCard + widget dropdown to use it.
Adds helper unit tests. |
| Red (card — preserves curated name) | `dc2e9231d` | Failing test: a
user-curated `user_metadata.name` distinct from `asset.name` must win
over the filename; plus non-regression guard that
curated-name-equal-to-hash still falls back to filename. |
| Green (card title helper) | `5decf3a2b` | Adds `getAssetCardTitle`
(curated name when distinct, else `getAssetDisplayFilename`). AssetCard
title + delete-dialog swap to it; widget dropdown stays on
`getAssetDisplayFilename`. |

Side-effect audit (`getAssetFilename` still canonical):
- `createModelNodeFromAsset.ts`, `createAssetWidget.ts`,
`missingModelScan.ts`, `useComboWidget.ts`, `useWidgetSelectItems.ts`
asset-mode `name` — all still resolve through `getAssetFilename`, so
model-asset widget serialization and missing-model matching are
unaffected.

## Screenshots

### As is — asset browser card

<img width="1269" height="899" alt="Screenshot 2026-04-21 at 10 49 41
AM"
src="https://github.com/user-attachments/assets/7cffb585-4e64-4037-8bb1-5dd40215597e"
/>

### To be

<img width="1145" height="533" alt="Screenshot 2026-04-25 at 7 25 17 PM"
src="https://github.com/user-attachments/assets/8f12388a-16df-4892-83b4-c8d1f033f190"
/>


_Verified locally via `pnpm dev:cloud`: asset browser cards preserve
user-curated display names, Cloud-hash cases render the filename, and
the Load Image output dropdown also renders the human-readable filename.
The selected value still serializes the hash path._

## Test plan
- [ ] \`pnpm test:unit -- AssetCard.test\` passes (4 cases:
metadata.filename, display_name fallback, curated-name preservation,
curated-name==hash fallback)
- [ ] \`pnpm test:unit -- assetMetadataUtils.test\` passes (53 cases
incl. new helper coverage)
- [ ] \`pnpm test:unit -- useWidgetSelectItems.test\` passes (26 cases)
- [ ] \`pnpm test:unit -- useMissingModelInteractions.test\` passes
(guards against model-asset regressions)
- [ ] Asset browser modal: user-renamed model shows curated name; Cloud
hash outputs show filename
- [ ] Load Image node → widget dropdown → Outputs tab shows original
filenames
- [ ] Delete-confirm dialog references the same curated name as the card
title
- [ ] Model-asset widgets (Checkpoint / LoRA / etc.) still serialize the
model filename
- [ ] Submitting a workflow that references a Cloud output still
executes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11492-fix-use-getAssetFilename-in-asset-browser-to-avoid-showing-hashes-3496d73d36508148b8a3fb0482fa668e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 03:16:18 +00:00
Dante
25f493bd30 test(assets): add E2E spec for sort options (#11634)
## Summary

Adds 6 @cloud-tagged Playwright tests covering the assets sidebar sort
menu (newest/oldest/longest/fastest). Phase 4 of the assets-test plan.

**Stacked on #11632** — base will retarget to \`main\` once the
foundation PR merges. Independent of #11633 (filter E2E) and can be
reviewed in parallel.

## Changes

- **What**: New file `browser_tests/tests/sidebar/assets-sort.spec.ts`.
Uses the `createJobsWithExecutionTimes()` factory and the
`sortLongestFirst` / `sortFastestFirst` locators added in #11632.
- **Coverage**:
  - Settings menu exposes all four sort options in cloud mode
  - Default order is newest first (descending `create_time`)
  - "Oldest first" reverses the order
  - "Longest first" puts the slowest execution at the top
  - "Fastest first" puts the quickest execution at the top
  - Sort persists across search-input edits
- **Breaking**: none

## Review Focus

- **Misaligned (create_time, duration) axes**: fixture data is
deliberately constructed so newest/oldest and longest/fastest produce
distinct orderings — no test can false-pass by satisfying a different
sort. See the table comment at the top of the spec.
- **`@cloud` tag is required**: sort options are gated behind
`:show-sort-options="isCloud"`, which depends on the compile-time
`__DISTRIBUTION__` flag. Tests run only against the `cloud` Playwright
project.
- **Local verification needed**: maintainer should verify with `pnpm
dev:cloud` + `pnpm test:browser:local --project cloud --grep "sort
options"` before merging — I could not run the cloud dev server
end-to-end in my environment.

Fixes #10779

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11634-test-assets-add-E2E-spec-for-sort-options-34e6d73d365081a79facde5bde2e18c6)
by [Unito](https://www.unito.io)
2026-04-27 12:26:08 +09:00
Comfy Org PR Bot
b8b5e1ec1f 1.44.11 (#11656)
Patch version increment to 1.44.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11656-1-44-11-34f6d73d3650812aa813ee9efb11dced)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-27 02:14:50 +00:00
Terry Jia
59ef69f355 test: add unit tests for useCoordinateTransform mask editor composable (#11640)
## Summary

Add unit tests for `useCoordinateTransform` mask editor composable,
raising coverage from 2.43% to 100% (statements / branches / functions /
lines).

## Changes

- **What**: Add
`src/composables/maskeditor/useCoordinateTransform.test.ts` (14 tests)
covering both `screenToCanvas` and `canvasToScreen`: identity (display
matches bitmap), uniform downscale (bitmap larger than display),
`pointerZone`-vs-`canvasContainer` offset, non-uniform per-axis scaling,
screen↔canvas round-trip, and the three "element missing" branches
(`pointerZone` / `canvasContainer` / `maskCanvas` null) that should warn
and return `{x:0,y:0}`.

## Review Focus

- Mocked `createSharedComposable` to a pass-through so each test gets a
fresh transform reading the latest `mockStore` refs (otherwise the
shared instance captures stale element references between tests).
- DOM rects are stubbed via `vi.spyOn(el, 'getBoundingClientRect')`
rather than constructing fake DOMRects, so `unref(...)` in the
composable still receives a real `HTMLElement` / `HTMLCanvasElement`.
- Round-trip test (`screenToCanvas` → `canvasToScreen`) verifies the two
functions are mathematical inverses under the offset + scale
combination, which is the actual invariant the rest of the editor relies
on.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by public method, explicit `MockStore` type alias, helper
factories `createElementWithRect` / `createCanvasWithRect`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11640-test-add-unit-tests-for-useCoordinateTransform-mask-editor-composable-34e6d73d3650814d95bdef66e36328e8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:41 -04:00
Terry Jia
9ad052467d test: add unit tests for useKeyboard mask editor composable (#11639)
## Summary

Add unit tests for `useKeyboard` mask editor composable, raising
coverage from 0% to 100% (statements/lines/functions, 95.65% branch).

## Changes

- **What**: Add `src/composables/maskeditor/useKeyboard.test.ts` (17
tests) covering key tracking (`isKeyDown`), space-key
blur/preventDefault, undo/redo shortcuts (Ctrl/Meta+Z, Ctrl+Shift+Z,
Ctrl+Y), modifier-key edge cases (Alt suppression, no-modifier no-op,
Ctrl+Shift+Y ignored), `window blur` clearing keys, and listener
teardown via `removeListeners`.

## Review Focus

- Mock surface is intentionally minimal — only `useMaskEditorStore` is
mocked because the composable only reaches
`store.canvasHistory.{undo,redo}`.
- `afterEach(keyboard.removeListeners)` is required: the composable
attaches listeners to `document` / `window`, so without teardown earlier
test instances leak handlers and inflate mock call counts in later
tests.
- Tests dispatch real `KeyboardEvent`s via `document.dispatchEvent`
rather than calling the internal handlers directly, so they exercise the
actual `addEventListener` wiring.
- Test style aligned with existing mask editor tests: `should ...`
naming, `describe` grouped by public method, explicit `MockStore` /
`MockCanvasHistory` type aliases.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11639-test-add-unit-tests-for-useKeyboard-mask-editor-composable-34e6d73d36508129b437d0270d9424d8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:19 -04:00
Dante
aa730c8cb5 test(assets): add unit tests and E2E fixture groundwork for sidebar filter & sort (#11632)
## Summary

Lays the test foundation for the two open assets-testing issues — Phase
1 (fixtures + page object) and Phase 2 (unit tests). Phase 3/4 (the
actual E2E specs) follow in stacked PRs.

## Changes

- **What**: 27 new unit tests covering `useMediaAssetFiltering`,
`MediaAssetFilterMenu`, and `MediaAssetSettingsMenu`; reusable
Playwright fixture factories for diverse media kinds and execution-time
specs; new locators + helpers on `AssetsSidebarTab` for the filter menu
and longest/fastest sort options. No production code touched.
- **Breaking**: none

### New files
- `src/platform/assets/composables/useMediaAssetFiltering.test.ts` — 11
cases: single/multi-OR media-type filter, `'3D'` → `'3d'` filename
normalization, exclusion of unsupported kinds, all four sort modes,
`created_at` fallback when `user_metadata.create_time` is absent,
filter+sort composition.
- `src/platform/assets/components/MediaAssetFilterMenu.test.ts` — 6
cases: checkbox rendering, prop-driven `aria-checked`, click toggling
(add/remove/append), keyboard activation (Enter/Space).
- `src/platform/assets/components/MediaAssetSettingsMenu.test.ts` — 10
cases: view-mode v-model, `showSortOptions` and `showGenerationTimeSort`
visibility gates, `v-model:sortBy` round-trip for
newest/oldest/longest/fastest.

### Extended files
- `browser_tests/fixtures/helpers/AssetsHelper.ts` — added
`MediaKindFixture` type, optional `mediaKind` shorthand on
`createMockJob` (sets both filename extension and
`preview_output.mediaType`), plus `createMixedMediaJobs(kinds)` and
`createJobsWithExecutionTimes(specs)` factories for unambiguous
filter/sort assertions.
- `browser_tests/fixtures/components/SidebarTab.ts` — added
`filterButton`, per-type checkbox locators
(`filterImage/Video/Audio/3DCheckbox`), `sortLongestFirst`,
`sortFastestFirst`, plus `openFilterMenu()`, `filterCheckbox(kind)`,
`toggleMediaTypeFilter(kind)`, and `getAssetCardOrder()` helpers.

## Review Focus

- **Naming of `MediaKindFixture` values** — `'images'` is plural to
match existing API conventions emitted by the backend /
`useMediaAssetGalleryStore`; `'video' | 'audio' | '3D'` follow the
singular `MediaKind` type. Open to renaming if a unified shape is
preferred.
- **Filter button locator strategy** — `MediaAssetFilterButton` has no
`aria-label`, so the page object targets it via the
`icon-[lucide--list-filter]` class. Happy to add a `data-testid` or
`aria-label` to the source component if reviewers prefer a more durable
hook (would be a one-line source change in a follow-up).

## Follow-up PRs

- Phase 3 (E2E for media-type filter) → closes #10780
- Phase 4 (E2E for asset sort) → closes #10779

Both are stacked on this branch and can be reviewed/merged in either
order once this lands.

References #10779, #10780.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11632-test-assets-add-unit-tests-and-E2E-fixture-groundwork-for-sidebar-filter-sort-34e6d73d3650815c9900e5fd7cc7eab0)
by [Unito](https://www.unito.io)
2026-04-27 01:46:50 +00:00
Terry Jia
cc1fe65348 test: add unit tests for maskEditorDataStore (#11641)
## Summary

Add unit tests for `maskEditorDataStore` Pinia store, raising coverage
from 0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorDataStore.test.ts` (13 tests)
covering initial state, the three `computed` predicates
(`hasValidInput`, `hasValidOutput`, `isReady` including the `isLoading`
interaction), the `setLoading(loading, error?)` action across its three
branches (no error arg, truthy error arg, empty-string error arg — empty
string is falsy so it must NOT clobber an existing `loadError`), and
`reset()` clearing every field including derived predicates.

## Review Focus

- Uses `createTestingPinia({ stubActions: false })` so action
implementations actually run, matching the pattern used by other store
tests in `src/stores/` (e.g. `dialogStore.test.ts`).
- The `setLoading('', '')` test guards a real branch in the source — `if
(error)` skips assignment for empty strings, so callers can't
accidentally clear a previous error by passing `''`. Worth keeping if
anyone tightens the guard later.
- `reset()` test asserts both raw refs and the three computed values
flip back to `false` / `null`, so a future regression in either
direction is caught.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed property/action, `createImage` / `createCanvas` /
`createOutputData` helpers to keep arrange blocks short.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11641-test-add-unit-tests-for-maskEditorDataStore-34e6d73d36508121b8e5d185178310ab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 21:38:38 -04:00
Terry Jia
0f66f76b87 test: add unit tests for mask editor control components (#11642)
## Summary

Add unit tests for the three mask editor control components
(`DropdownControl`, `SliderControl`, `ToggleControl`), raising each from
partial (20% / 37.5% / 44.4%) to 100% across all coverage dimensions.

## Changes

- **What**: Add three sibling test files under
`src/components/maskeditor/controls/`:
- `DropdownControl.test.ts` (5 tests): label render, normalization of
`string[]` options into `{label,value}`, pass-through of `{label,value}`
options, `modelValue` reflected as the selected option,
`update:modelValue` emitted on change.
- `SliderControl.test.ts` (4 tests): label render,
`min`/`max`/`step`/`modelValue` exposed on `<input type="range">`,
`step` defaults to `1` when omitted, `update:modelValue` emitted as a
`number` (not string) on input.
- `ToggleControl.test.ts` (5 tests): label render, `modelValue`
reflected as `checked`, `update:modelValue` emitted as `true` / `false`
on toggle.

## Review Focus

- Stack matches the project's prevailing Vue test pattern
(`@testing-library/vue` + `@testing-library/user-event`, e.g.
`Badge.test.ts`, `BatchCountEdit.test.ts`).
- `update:modelValue` is asserted via the `'onUpdate:modelValue'`
callback prop rather than `emitted()` — keeps tests focused on
observable behavior and avoids reaching into the wrapper.
- `SliderControl` uses `fireEvent.input` instead of
`userEvent.type/clear`: `<input type="range">` is non-editable for
`userEvent` (`clear() is only supported on editable elements`). The
single eslint-disable for `testing-library/prefer-user-event` is
annotated with the reason.
- `DropdownControl` test guards both branches of the `string` →
`{label,value}` normalization (string array path and pre-normalized
object array path), since that's the only meaningful logic in the file.
- Style aligned with sibling tests: `should ...` naming, per-file
`renderComponent` helper accepting a `props` override and an `onUpdate`
callback parameter.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11642-test-add-unit-tests-for-mask-editor-control-components-34e6d73d3650812aba2ae34a760489e2)
by [Unito](https://www.unito.io)
2026-04-26 21:38:16 -04:00
Terry Jia
bc16865019 test: add unit tests for useToolManager mask editor composable (#11643)
## Summary

Add unit tests for `useToolManager` mask editor composable, raising
coverage from 0% to 100% (statements / functions / lines, 97.53%
branch).

## Changes

- **What**: Add `src/composables/maskeditor/useToolManager.test.ts` (35
tests) covering:
- `switchTool`: store update, layer auto-switch via
`newActiveLayerOnSet`, custom-cursor branch, no-cursor (default
`'none'`) branch, missing-`pointerZone` no-throw guard.
- `setActiveLayer`: rgb-while-mask-only-tool → swap to `PaintPen`,
mask-while-`PaintPen` → swap to `MaskPen`, no-swap path.
- `updateCursor`: same custom-cursor / default-cursor split plus
`brushPreviewGradientVisible = false` post-condition.
- `currentTool` watcher: clears `lastColorSelectPoint` only when leaving
`MaskColorFill`.
- `handlePointerDown`: touch-ignore, pen pointer registration,
middle-button pan, space+left pan, `MaskPen`/`PaintPen` left-button
drawing, `PaintPen` continue-drawing branch (`button !== 0 && buttons
=== 1`), `MaskBucket` flood fill (with coord transform),
`MaskColorFill`, alt+right brush adjustment, right-click drawing for
drawing tools, no-op for non-drawing tools.
- `handlePointerMove`: touch-ignore, cursor position update,
middle-button pan, space+left pan, non-drawing-tool ignore, alt+right
brush adjustment while `isAdjustingBrush`, left/right drag drawing.
- `handlePointerUp`: state cleanup (`isPanning` / `brushVisible` /
`isAdjustingBrush`), pen pointer removal, touch-pointer early bail
before `drawEnd`.

## Review Focus

- Mock store is wrapped in `reactive()` so the `watch(() =>
store.currentTool, ...)` actually fires when tests mutate `currentTool`.
Plain object mocks would silently no-op the watcher branch.
- Each `setup()` runs `useToolManager` inside its own `effectScope`,
stopped in `afterEach`. Without scoping, watchers from previous tests
stay attached to the shared reactive store and accumulate (a single
mutation in test N would call `clearLastColorSelectPoint` N times).
- Mocked `app.extensionManager.setting.get` because `useBrushDrawing`
factory reads two settings synchronously at construction time. The mock
returns deterministic defaults so we don't need `useSettingStore`
plumbing.
- Pointer-event factory builds the minimal shape (`button` / `buttons` /
`pointerType` / `offset*` / `client*` / `altKey` / `pointerId`) — no
jsdom `PointerEvent` constructor noise. `preventDefault` is a `vi.fn()`
because the source calls it unconditionally.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function/watcher, typed `MockStore`, helper
`pointerEvent({ ... })` and `setup()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11643-test-add-unit-tests-for-useToolManager-mask-editor-composable-34e6d73d36508184b017ebd04626b29d)
by [Unito](https://www.unito.io)
2026-04-26 20:49:01 -04:00
Terry Jia
206a367379 test: add unit tests for useMaskEditor composable (#11644)
## Summary

Add unit tests for `useMaskEditor` composable, raising coverage from 0%
to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/composables/maskeditor/useMaskEditor.test.ts` (7
tests) covering `openMaskEditor`:
- Happy path: dialog opened once, `node` forwarded as a prop, header /
content components attached.
- Modal dialog config (`modal` / `maximizable` / `closable` flags)
forwarded to PrimeVue dialog props.
- Acceptance path for nodes with no `imgs` but `previewMediaType ===
'image'`.
- Three guard paths that should log and bail: `node` is null, node with
empty `imgs` and no image preview, node with empty `imgs` and a
non-image preview type (e.g. `'video'`).

## Review Focus

- Mocked `useDialogStore` with a single shared `showDialog` spy — the
only contract under test is "we forwarded these props to the store
action", so instantiating Pinia would just add noise.
- `TopBarHeader.vue` and `MaskEditorContent.vue` are stubbed because
they pull in the full mask-editor render tree; we only assert they're
forwarded as `headerComponent` / `component`, not what they render.
- `console.error` is spied per-test so the bail messages are observable
but don't pollute runner output.
- `nodeWithImage` factory uses a structural `NodeShape` (`{ imgs?,
previewMediaType? }`) rather than `Partial<LGraphNode>` because the real
`LGraphNode` type requires a `LGraphNodeConstructor`-shaped
`constructor` field, which would force every test to construct a full
graph node — irrelevant to the contract being tested.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function (`openMaskEditor`), helper factory.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11644-test-add-unit-tests-for-useMaskEditor-composable-34e6d73d365081e98336db0a92c37ccf)
by [Unito](https://www.unito.io)
2026-04-26 20:48:28 -04:00
Terry Jia
7e8ede376b test: add unit tests for maskEditorStore (#11645)
## Summary

Add unit tests for `maskEditorStore` Pinia store, raising coverage from
0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorStore.test.ts` (30 tests)
covering:
- Brush setters: `setBrushSize` (1–250), `setBrushOpacity` (0–1),
`setBrushHardness` (0–1), `setBrushStepSize` (1–100) — each tested at
lower bound, upper bound, and in-range.
  - `resetBrushToDefault`: documents the exact default brush shape.
- Other clamped setters: `setPaintBucketTolerance` /
`setColorSelectTolerance` / `setMaskTolerance` (0–255), `setFillOpacity`
/ `setSelectionOpacity` (0–100), `setMaskOpacity` (0–1), `setZoomRatio`
(0.1–10).
- `setPanOffset` / `setCursorPoint`: copy-by-value semantics — mutating
the input after the call must not leak into store state.
  - `resetZoom` / `triggerClear`: monotonic counter bumps.
- `maskColor` computed: `Black`, `White`, `Negative` blend modes plus
the `default:` fallback for unknown values.
  - `canUndo` / `canRedo` proxy through to mocked `useCanvasHistory`.
- Canvas → ctx watchers: setting `maskCanvas` / `rgbCanvas` /
`imgCanvas` derives the corresponding `*Ctx` via `getContext('2d', {
willReadFrequently: true })`. Clearing the canvas leaves the previous
ctx in place (parametrized via `it.each` for all three).
- `resetState`: restores all non-DOM state to documented defaults;
explicitly verifies DOM refs (`maskCanvas` / `pointerZone` / `image`)
are NOT cleared so the editor can reuse mounted elements after a reset.

## Review Focus

- `useCanvasHistory` is mocked via `vi.hoisted` so each test gets the
same exposed `canUndo` / `canRedo` refs while the store's internal
`canvasHistory` reference is untouched. Without this, the store would
call into the real history with `null` canvas refs.
- `setPanOffset` / `setCursorPoint` tests mutate the input *after* the
call — that's the actual behavioral contract (defensive copy via
spread), not a default-value check.
- `resetState` test sets *every* field to a non-default before calling,
so the test fails if `resetState` ever forgets to reset a field. Final
assertions are positive (matches default), not weak negative checks.
- The "DOM refs preserved on reset" assertion is the
surprising-on-purpose part: a future refactor that adds
`maskCanvas.value = null` to `resetState` would break the editor's
ability to reuse mounted canvases after clearing internal state.
- `it.each` for the three canvas/ctx pairs covers the watcher's null
branch without three near-duplicate tests.
- `makeCanvas` overrides `canvas.getContext` directly rather than using
`vi.spyOn` because `HTMLCanvasElement.getContext` has overloads (2d /
webgl / webgpu / bitmaprenderer) and TypeScript picks the GPU overload
by default for spy return type inference.
- Style aligned with sibling `maskEditorDataStore.test.ts`:
`createTestingPinia({ stubActions: false })`, `should ...` naming,
`describe` grouped by exposed property/action, no default-only
change-detector tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11645-test-add-unit-tests-for-maskEditorStore-34e6d73d3650818e9855cd9f9f13e62a)
by [Unito](https://www.unito.io)
2026-04-26 20:47:58 -04:00
Terry Jia
7bfbd0d7f3 test: add unit tests for mask editor settings panels (#11647)
## Summary

Add unit tests for the five mask editor settings panel components,
raising each from 0–35% to ~98–100% across coverage dimensions.

## Changes

- **What**: Add 5 sibling test files under `src/components/maskeditor/`
(47 tests total):
- `PaintBucketSettingsPanel.test.ts` (4): both sliders bind to / write
through to `setPaintBucketTolerance` / `setFillOpacity`. **100%**.
- `ColorSelectSettingsPanel.test.ts` (8): all 7 controls (4 sliders, 2
toggles, 1 dropdown) bind to and write through to the right store
fields/setters; method dropdown casts to `ColorComparisonMethod`.
**100%**.
- `BrushSettingsPanel.test.ts` (13): shape buttons (Arc/Rect), reset to
default, four numeric inputs (size/opacity/hardness/step), logarithmic
size slider including the cached-raw-value path
(`Math.round(Math.pow(250, x))`), color input v-model, color input ref
forwarded to / cleared from store on mount/unmount.
- `ImageLayerSettingsPanel.test.ts` (17): mask opacity slider + canvas
style sync, blend-mode select with all 3 enum values + default fallback,
three layer-visibility checkboxes (with and without canvas refs),
activate-layer button forwarding to `toolManager.setActiveLayer`,
disabled-when-active and "show paint button only when Eraser" branches,
base image preview src binding.
- `SettingsPanelContainer.test.ts` (5): tool → component routing
(`MaskBucket` → bucket panel, `MaskColorFill` → color panel, anything
else → brush panel).

## Review Focus

- Stack matches sibling control tests (`@testing-library/vue` +
`userEvent` + stub child controls). Each panel's child `SliderControl` /
`ToggleControl` / `DropdownControl` is replaced by a minimal `<button>`
stub that emits `update:modelValue` on click, so panel logic is
decoupled from control internals (already covered by their own tests).
- Two panels (`Brush`, `ImageLayer`) need real reactivity
(`brushSettings.size` changes through a setter must propagate to the
slider's modelValue computed; `currentTool` / `activeLayer` mutations
between tests). They use `reactive()` from Vue at top level + a `let
mockStore` reset in `beforeEach`. Plain object mocks would either
silently no-op or leak state between tests.
- The two reactive panels enable file-level `eslint-disable
testing-library/no-container, testing-library/no-node-access` because
the unlabeled DOM (shape divs, unlabeled `<input type="number">`/`<input
type="color">`/`<input type="checkbox">`/`<select>`) genuinely can't be
queried via `screen.getByRole/Label`. Each disable has a comment
explaining why.
- `BrushSettingsPanel` has a non-trivial cached-value branch in
`brushSizeSliderValue` computed: when `rawSliderValue` and `brushSize`
are in sync, the getter returns the raw float instead of recomputing
`log(size)/log(250)`. Test stubs `setBrushSize` to actually write back
so the next read takes the cached path.
- `ImageLayerSettingsPanel` has a `display: 'block' | 'none'` style
binding. happy-dom doesn't populate `el.style.display` from inline style
strings reliably, so the test asserts via `el.getAttribute('style')`
instead.
- `SettingsPanelContainer` uses inline component stubs that render
unique text — assertion is just `textContent.toContain('brush-panel')`
etc. No need for component-instance probing.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature (button group, slider, etc.), `vi.hoisted` for mock
state where reactivity isn't required.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11647-test-add-unit-tests-for-mask-editor-settings-panels-34e6d73d36508117911bc0850ce085e1)
by [Unito](https://www.unito.io)
2026-04-26 20:27:26 -04:00
Terry Jia
32b266f3e9 test: add unit tests for SidePanel, MaskEditorButton, and BrushCursor (#11648)
## Summary

Add unit tests for three small mask editor Vue components (`SidePanel`,
`MaskEditorButton`, `BrushCursor`), all reaching **100%** across
coverage dimensions.

## Changes

- **What**: Add 3 sibling test files (17 tests total):
- `SidePanel.test.ts` (3): renders both `SettingsPanelContainer` and
`ImageLayerSettingsPanel`; forwards `toolManager` prop through to
`ImageLayerSettingsPanel`; works with the prop omitted.
- `MaskEditorButton.test.ts` (3): renders with localized `aria-label`
when `isSingleImageNode` is true; hidden via `v-show` (style `display:
none`) when false; click executes `Comfy.MaskEditor.OpenMaskEditor`
command.
- `BrushCursor.test.ts` (11): opacity 1 / 0 from `brushVisible`; size =
2 × effective brush size × zoom (with hardness scaling); border-radius
50% / 0% for Arc / Rect; position from `cursorPoint + panOffset -
radius`, with optional `containerRef.getBoundingClientRect` offset;
gradient preview hidden / shown; flat fill at `effectiveHardness === 1`.

## Review Focus

- `SidePanel` test stubs both child panels to bare `<div data-testid>`
so the test verifies wiring (prop forwarding, child rendering) without
being affected by the children's i18n / store dependencies.
- `MaskEditorButton` mocks `useCommandStore.execute` and
`useSelectionState.isSingleImageNode` (as a Vue ref). Real i18n via
`createI18n` + `globalInjection: true` — the template uses `$t(...)`
which requires global injection in composition mode.
- For the v-show hidden case, `getByLabelText('...', { selector:
'button' })` works where `getByRole('button', { hidden: true })` doesn't
reliably resolve through the `<Button>` wrapper component.
- `BrushCursor` uses `reactive()` mock store and queries the brush /
gradient elements by id (`#maskEditor_brush`,
`#maskEditor_brushPreviewGradient`). File-level eslint-disable for
`testing-library/no-node-access` because the component's anchor elements
are styled divs without ARIA roles or labels.
- The `radial-gradient` (hardness < 1) branch of `gradientBackground` is
intentionally **not** asserted via rendered DOM: the computed returns a
multi-line template literal that happy-dom's CSS parser drops entirely.
The math (`getEffectiveBrushSize` / `getEffectiveHardness`) is covered
by `brushUtils.test.ts`. v8 reports 100% branch coverage because Vue
evaluates the computed regardless of whether the resulting style string
is parsed by the DOM.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for command-store / selection-state
mocks, real i18n via `createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11648-test-add-unit-tests-for-SidePanel-MaskEditorButton-and-BrushCursor-34e6d73d365081e38c4afee32ddf2b0b)
by [Unito](https://www.unito.io)
2026-04-26 20:08:03 -04:00
Terry Jia
2d4ca9c387 test: add unit tests for PointerZone and ToolPanel (#11649)
## Summary

Add unit tests for `PointerZone` and `ToolPanel` mask editor components,
raising each from 0% to **91.89% / 100%** respectively.

## Changes

- **What**: Add 2 sibling test files (24 tests total):
- `PointerZone.test.ts` (13): mount exposes root element to
`store.pointerZone`; pointer events (down/move/up) forward to
`toolManager.handlePointer*`; `pointerleave` hides brush + clears
cursor; `pointerenter` calls `toolManager.updateCursor`; touch events
(start/move/end) forward to `panZoom.handleTouch*`; wheel zooms then
updates cursor with wheel coords; `isPanning` watcher sets cursor to
"grabbing" then back via `updateCursor`; contextmenu's default is
prevented.
- `ToolPanel.test.ts` (11): one container rendered per `allTools`; icon
HTML for each tool; current tool highlighted with
`maskEditor_toolPanelContainerSelected` class while others are not;
clicking a container calls `toolManager.switchTool` with the matching
enum value; zoom indicator rounds `displayZoomRatio * 100`; dimensions
text reflects `image.width × image.height` (or empty when no image);
clicking the zoom indicator calls `store.resetZoom`.

## Review Focus

- `PointerZone` mocks `useToolManager` / `usePanAndZoom` to plain
function bags via factory helpers. The component is mostly forwarding,
so the test's value is in *event mapping* (which event triggers which
handler), not handler internals.
- happy-dom doesn't propagate `clientX` / `clientY` through the
`WheelEvent` constructor; the wheel test sets them via
`Object.defineProperty` after construction. Without this,
`updateCursorPosition` reads `undefined`.
- `PointerZone` ends at 91.89% statements / 70% branch — uncovered lines
are the `onMounted` early-return when `pointerZoneRef.value` is
`undefined` (always set in tests) and the watcher's same guard. Both
unreachable in normal use, intentionally not covered.
- `ToolPanel` mocks `iconsHtml` to `<svg data-testid="icon-${tool}" />`
markers, letting tests assert per-tool rendering via `getByTestId`. Real
i18n via `createI18n` for the reset-zoom tooltip text.
- `getToolContainers` queries by class because tool buttons are
unlabeled `<div>`s with click handlers (no role / aria); a file-level
`eslint-disable testing-library/no-node-access` covers this and the
dimensions-span placeholder query.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `reactive()` mock store + `let mockStore` reset in
`beforeEach`, real i18n where keys are user-visible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11649-test-add-unit-tests-for-PointerZone-and-ToolPanel-34e6d73d3650817daf85c0d33d16899d)
by [Unito](https://www.unito.io)
2026-04-26 20:07:35 -04:00
Dante
177224452e test(assets): add E2E tests for media type filter (#10784)
## Summary

Add Playwright E2E tests for media type filter in assets sidebar.

## Changes

- Add `filterButton`, `filterCheckbox(label)`, `openFilterMenu()` to
`AssetsSidebarTab` fixture
- New `Assets sidebar - media type filter` describe block with 3 tests:
- Filter menu shows all 4 media type checkboxes (Image, Video, Audio,
3D)
  - Unchecking image filter hides image assets
  - Re-enabling filter restores hidden assets
- Mock jobs use distinct file extensions (.png, .mp4, .mp3) since
filtering is extension-based

## Review Focus

Tests tagged `@cloud` — filter button is gated behind `isCloud` in
`MediaAssetFilterBar.vue`.

Fixes #10780

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10784-test-assets-add-E2E-tests-for-media-type-filter-3356d73d3650810a8ecdd102e9f5b47e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:15:03 +09:00
Terry Jia
6bf75b4cf0 refactor(load3d): introduce ModelAdapter abstraction for the loader switch (#11627)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
the per-format loader logic needs to live behind a stable seam before
splat-specific fixes (orientation, async-decoder waits, GPU dispose,
custom bounds) and capability-driven UX gating can be added without
touching `LoaderManager`'s switch every time.

## Summary

Pure refactor. Extracts the per-extension switch inside `LoaderManager`
into three `ModelAdapter` implementations and wires the manager to
dispatch through them. **No behavior change** — same loader code paths,
same outputs, same fallbacks. Sixth in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**:
- `ModelAdapter.ts` (new): defines the `ModelAdapter` interface (`kind`,
`extensions`, `capabilities`, `load`), a `ModelLoadContext` that exposes
only the `SceneModelManager` surface adapters need (`setOriginalModel`,
`registerOriginalMaterial`, `standardMaterial`, `materialMode`), and a
shared `fetchModelData(path, filename)` helper.
- `MeshModelAdapter.ts` (new): owns `stl`, `fbx`, `obj`, `gltf`, `glb`.
Each branch is a 1:1 lift of the corresponding `case` from
`LoaderManager.loadModelInternal` on `main`.
- `PointCloudModelAdapter.ts` (new): owns `ply`. Includes the existing
`FastPLYLoader` / `PLYLoader` fallback and the `pointCloud` vs mesh
branching logic.
- `SplatModelAdapter.ts` (new): owns `spz`, `splat`, `ksplat`. Wraps the
`SplatMesh` in a `Group` exactly like the previous `loadSplat` did.
- `LoaderManager.ts`: now owns just an adapter array (default = the
three above) and a small dispatch path. `pickAdapter` matches by
extension and routes PLY → splat when the `Comfy.Load3D.PLYEngine`
setting is `sparkjs` (preserving the previous routing).
`getCurrentAdapter()` is the new public reader used by `Load3d`.
- `Load3d.isSplatModel` / `isPlyModel` now query
`loaderManager.getCurrentAdapter()?.kind` instead of doing
tree-introspection (`containsSplatMesh`) or `instanceof
THREE.BufferGeometry` checks. Same return values, decoupled from the
model shape.
- `LoaderManagerInterface` no longer exposes the per-format loader
fields (`gltfLoader`, `objLoader`, etc.); those are now
adapter-internal.
- `SceneModelManager` is **unchanged** in this PR. Its existing
`containsSplatMesh()` traversal and PLY material-mode rebuild stay put;
a follow-up PR refactors them once capability gating is in place.

## Review Focus

- **Loader equivalence**: the body of every `case` in `main`'s
`LoaderManager.loadModelInternal` is now in the corresponding adapter's
`load()` method. Easiest way to verify: diff `main`'s
`LoaderManager.loadModelInternal` against the four `load()` bodies and
confirm each branch's behavior (file fetch + parse + material wiring +
group wrapping) is byte-identical.
- **Dispatch parity**: `pickAdapter` produces the same routing as `main`
— extension match first, then the PLYEngine === 'sparkjs' override
hoisted up from inside the old `loadPLY`.
- **Capability fields are dormant**: the `ModelAdapterCapabilities`
record (`fitToViewer`, `materialModes`, `fitTargetSize`, …) is declared
on every adapter but **not consumed anywhere in this PR**.
SceneModelManager / Load3d / Load3DControls still read no capability
data. The follow-up PR turns these on.
- **`setOriginalModel` / `registerOriginalMaterial`**: adapters now go
through the `ModelLoadContext` getter rather than reaching into
`modelManager` directly. The context's `standardMaterial` and
`materialMode` are exposed via getters so a late-bound `materialMode` is
read at the actual call site, not snapshotted at context creation.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** |
| `MeshModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (new) | 97.22% | 61.11% | 75% | 97.22% |
| `SplatModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.17% | 86.66% | **100%**
|
| `Load3d.ts` (modified) | 6.63% | 0% | 13.68% | 6.7% |

All four new files are at or near 100% via dedicated unit tests for each
adapter (load happy path, error propagation, extension declarations,
capability shape). `LoaderManager.test.ts` exercises the dispatch logic
— extension matching, the `ply → splat` sparkjs override, the stale-load
discard, the load-context proxying — across 34 cases. The two changed
`Load3d.ts` methods (`isSplatModel`, `isPlyModel`) get dedicated tests
verifying they read the current adapter's `kind` and fall back to
`false` when none is loaded.

`Load3d.ts`'s overall 6.7% number is the pre-existing baseline — the
existing `Load3d.test.ts` covers façade methods via prototype injection
rather than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). PR-F's surface in
`Load3d.ts` is two method bodies, both covered by the new adapter-driven
kind queries test.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11627-refactor-load3d-introduce-ModelAdapter-abstraction-for-the-loader-switch-34d6d73d3650811b8a1ccc55b45100f2)
by [Unito](https://www.unito.io)
2026-04-26 18:32:51 -04:00
Christian Byrne
7a13340989 chore: add 301 redirects for old Framer case study URLs (#11654)
## Summary

Add 301 redirects for old Framer case study URLs to new `/customers/`
pages.

## Changes

- Add `redirects` config to `apps/website/astro.config.ts` mapping two
old Framer enterprise case study URLs to their new Astro customer pages

## Testing

### Automated

- Website build succeeds with redirect pages generated
- Lint, typecheck, and format checks pass

### E2E Verification Steps

1. Deploy to preview
2. Visit
`/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping`
— should 301 redirect to `/customers/moment-factory/`
3. Visit
`/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui`
— should 301 redirect to `/customers/series-entertainment/`

Fixes #11583

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11654-chore-add-301-redirects-for-old-Framer-case-study-URLs-34e6d73d36508187a386eed3e25cf1b2)
by [Unito](https://www.unito.io)
2026-04-26 15:13:32 -07:00
Terry Jia
1b07e82ff7 fix: resolve mesh widget thumbnails via asset preview API (#11538)
## Summary
The Load3d select-model widget was passing the raw .glb URL as the item
preview_url, which browsers can't render as an image, producing the
broken-image icon on cloud/local asset-enabled servers.

Resolve thumbnails lazily from the asset API using the preview_id link
(matching Media3DTop's behavior), look up by basename to stay consistent
with the write path in useLoad3d, and fall back to a 3D-box placeholder
when no preview exists yet.

## Screenshots
before
<img width="1112" height="1333" alt="image"
src="https://github.com/user-attachments/assets/a8fa88ad-ab82-4951-be03-d28111322e30"
/>

after
with asset-enable on BE

https://github.com/user-attachments/assets/34b416af-5729-4ad0-bf17-722461ffc659

without asset-enable on BE
<img width="1026" height="1201" alt="image"
src="https://github.com/user-attachments/assets/71fd463f-ca77-4d63-85ed-01261d032d53"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11538-fix-resolve-mesh-widget-thumbnails-via-asset-preview-API-34a6d73d365081d2aefac044dab0dfc3)
by [Unito](https://www.unito.io)
2026-04-26 18:08:30 -04:00
Terry Jia
9f4c54eb24 refactor: extract Load3d right-click guard to load3dContextMenuGuard (#11625)
## Summary

Pull the right-click vs right-drag detection out of `Load3d` into a
sibling helper. Mechanical refactor — no behavior change. Third of four
small PRs splitting up the [`remove-ply-3dgs-nodes-squashed`
mega-commit.](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.)

## Changes

- **What**: New `load3dContextMenuGuard.ts` exports
`attachContextMenuGuard(target, onMenu, { isDisabled, dragThreshold })`.
It installs `mousedown` / `mousemove` / `contextmenu` listeners against
a single `AbortController` and returns one dispose function.
- `Load3d` now calls `attachContextMenuGuard(this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event), { isDisabled: () =>
this.isViewerMode })` and stores the returned disposer in a single
field. Drops four private fields (`rightMouseStart`, `rightMouseMoved`,
`dragThreshold`, `contextMenuAbortController`) plus the now-redundant
`showNodeContextMenu` private method.
- The 5px drag threshold and `isViewerMode` gating are preserved.

## Review Focus

- The three event handlers (`mousedown`, `mousemove`, `contextmenu`)
inside the new helper match the old inline implementations one-for-one —
same `e.button === 2` / `e.buttons === 2` checks, same call to
`exceedsClickThreshold`, same `preventDefault` + `stopPropagation`
ordering.
- `isDisabled: () => this.isViewerMode` replaces the inline `if
(this.isViewerMode) return` early-out — same gate, just lifted to a
callback.
- A single `AbortController.abort()` (in the returned disposer) replaces
the old four-field teardown in `Load3d.remove()`.
- 9 unit tests cover the helper: click vs drag distinction at the
threshold, drag-then-click reset, `isDisabled` short-circuit, and the
disposer detaching all three listeners.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `load3dContextMenuGuard.ts` (new) | **100%** | **93.33%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | 7.12% | 0% | 13.97% | 7.18% |

The single uncovered branch on `load3dContextMenuGuard.ts` (line 22) is
the default-parameter fallback for `dragThreshold` when the caller omits
it — `Load3d` always passes `{ isDisabled, dragThreshold: 5 }` through
`attachContextMenuGuard`'s second-arg destructure, so the default never
fires under the production call path. Adding a test that omits
`dragThreshold` would push it to 100%; left as-is to avoid a
change-detector test for a default value.

The `Load3d.ts` numbers are the pre-existing baseline on `main` —
`Load3d.test.ts` covers façade methods via prototype injection rather
than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). The
`initContextMenu` rewrite and the `remove()` teardown change both sit in
those same uninstantiated paths and rely on browser e2e for end-to-end
coverage, the same as before. Net: the click-vs-drag logic that
previously had no unit test is now ≥93% covered through the extracted
helper.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11625-refactor-extract-Load3d-right-click-guard-to-load3dContextMenuGuard-34d6d73d36508162aecef46553a3f50d)
by [Unito](https://www.unito.io)
2026-04-26 17:55:09 -04:00
Terry Jia
6f6fc88b0f test: add unit tests for MaskEditorContent main container (#11651)
## Summary

Add unit tests for `MaskEditorContent` (the mask editor's main
orchestration container), raising coverage from 0% to **94.11% / 83.72%
/ 83.33% / 94.11%** (statements / branches / functions / lines).

## Changes

- **What**: Add `src/components/maskeditor/MaskEditorContent.test.ts`
(12 tests) covering:
- **Mount**: keyboard listeners attached, ResizeObserver observes the
container, all 5 canvas refs assigned to the store before init runs.
- **Init flow**: `loader.loadFromNode` → `imageLoader.loadImages` →
`panZoom.initializeCanvasPanZoom` → `canvasHistory.saveInitialState` →
`brushDrawing.initGPUResources` → `initPreviewCanvas` chain runs in
order; child UI (`ToolPanel` / `PointerZone` / `SidePanel` /
`BrushCursor`) only renders after init succeeds; GPU preview canvas
resolution matches the mask canvas.
- **Init errors**: rejection from `loader.loadFromNode` or
`panZoom.initializeCanvasPanZoom` is caught, logged, and triggers
`dialogStore.closeDialog()`.
- **ResizeObserver**: callback invokes `panZoom.invalidatePanZoom()`
(captured the constructor argument to call it manually).
  - **Drag**: `Ctrl+drag` is preventDefault'd; plain drag is not.
- **Unmount**: cleanup runs `brushDrawing.saveBrushSettings`,
`keyboard.removeListeners`, `canvasHistory.clearStates`,
`store.resetState`, `dataStore.reset`.

## Review Focus

- Heavy mock surface (10 modules): the 3 stores, 5 composables, plus 4
child Vue components and `LoadingOverlay`. All mocks are `vi.hoisted`
module-level. `mockStore` is `reactive()` because the source mutates
`activeLayer` (visible in template binding), `maskCanvas`, etc.; the
rest are plain function bags.
- Child components are stubbed to bare `<div data-testid>` so init
reveal can be asserted via `screen.findByTestId(...)` without engaging
their real implementations (each has its own test file).
- `MockResizeObserver` captures the constructor callback in module-level
`lastResizeCallback`. The "invalidate on resize" test invokes it
manually with empty args — that's enough to exercise the source's `if
(panZoom) { await panZoom.invalidatePanZoom() }` branch since the
callback only consumes `panZoom` from closure.
- happy-dom doesn't propagate `ctrlKey` through the `DragEvent`
constructor, so the drag tests set it via `Object.defineProperty(event,
'ctrlKey', { value })` (same pattern used in `PointerZone.test.ts` for
wheel `clientX/Y`).
- 94.11% line coverage — the two uncovered blocks (`containerRef`
missing, canvas refs missing) are early-return error paths unreachable
when Vue successfully mounts; not worth constructing a fixture to
trigger.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` mocks reset via `beforeEach`,
`screen.findByTestId` for async render assertions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11651-test-add-unit-tests-for-MaskEditorContent-main-container-34e6d73d365081b38af2e057cb7daf9e)
by [Unito](https://www.unito.io)
2026-04-26 17:52:29 -04:00
Terry Jia
492bec28c8 test: add unit tests for TopBarHeader (#11650)
## Summary

Add unit tests for `TopBarHeader` mask editor dialog component, raising
coverage from 0% to **100%** across statements, branches, functions, and
lines.

## Changes

- **What**: Add `src/components/maskeditor/dialog/TopBarHeader.test.ts`
(17 tests) covering:
  - Localized title rendering.
  - Undo / Redo buttons forward to `store.canvasHistory.{undo,redo}`.
- Four transform buttons (rotate left / right, mirror horizontal /
vertical) call the matching `canvasTransform` action — parametrized via
`it.each`.
- All four transform error paths: rejected promise is caught, swallowed,
and logged with the right `[TopBarHeader] ... failed:` prefix.
- Invert calls `canvasTools.invertMask`; Clear calls both
`canvasTools.clearMask` and `store.triggerClear`.
- Save: hides brush, awaits `saver.save()`, closes the dialog on
success; switches button text to "Saving" while in-flight; restores
brush + button label and logs on save failure.
  - Cancel: closes the dialog with the `global-mask-editor` key.

## Review Focus

- All five composable / store dependencies are mocked at module level
via `vi.hoisted`: `useMaskEditorStore`, `useDialogStore`,
`useCanvasTools`, `useCanvasTransform`, `useMaskEditorSaver`. Only the
store needs `reactive()` (`brushVisible` flips during save flow); the
rest are plain function bags.
- `Button.vue` is stubbed to a thin `<button :disabled>` so role queries
(`getByRole('button', { name: ... })`) resolve cleanly without dragging
in the real UI button's classes / variants.
- Real i18n via `createI18n`. Icon buttons rely on `:title` for their
accessible name; text buttons rely on slot text — both are reachable
through `getByRole('button', { name: ... })`.
- The "Saving" text test uses an unresolved promise to keep the
in-flight state observable; `waitFor` + `void user.click(...)` lets us
assert without awaiting the click. The dangling promise resolves at the
end so vitest doesn't complain.
- `it.each` parametrizes the four transform success paths and
(separately) the four error paths, keeping the file tight.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for cross-test mocks, real i18n with
`createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11650-test-add-unit-tests-for-TopBarHeader-34e6d73d365081adab66e460cf56accb)
by [Unito](https://www.unito.io)
2026-04-26 17:51:44 -04:00
Comfy Org PR Bot
13b660a15b 1.44.10 (#11620)
Patch version increment to 1.44.10

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-26 05:36:11 +00:00
Terry Jia
b232831441 fix: stop duplicate node creation when dropping image on Vue nodes (#11541)
## Summary
handleDrop checked `handled === true` to gate stopPropagation, but
onDragDrop from useNodeDragAndDrop is async and always returns a
Promise, so the check never matched. The drop then bubbled to the
document handler in app.ts and spawned a new LoadImage node in addition
to the one that accepted the drop.

Updated onDragDrop to take an optional claimEvent flag — when true, it
calls preventDefault()/stopPropagation() synchronously inside the
handler, only after the sync acceptance check passes (valid files /
same-origin URI), and before any await.
The Vue node now just calls await node.onDragDrop(event, true).
Rejected payloads (cross-origin URI, files filtered out, no valid files)
skip the claim and bubble to the document fallback as before. The
remaining edge case is async URI fetch failures, which we can't
sync-detect without speculatively claiming.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/d79a5101-370b-4873-8365-5f9ce188731b



after


https://github.com/user-attachments/assets/8b787474-eab9-4060-8146-c4d8bb24ff9f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11541-fix-stop-duplicate-node-creation-when-dropping-image-on-Vue-nodes-34a6d73d36508113b153e31768602933)
by [Unito](https://www.unito.io)
2026-04-25 20:51:39 -04:00
Dante
996e362ba6 test: add unit tests for form-dropdown internals (#11441)
## Summary

Adds 32 unit tests across 3 files covering the internals of the
FormDropdown family (input, filter, menu item). Part of a
widget-test-coverage sequence.

## Changes

- **What**:
- `FormDropdownInput.test.ts` (14) — placeholder vs selected-items
display, label preference, multi-item join, select-click emit,
file-input rendering/accept/multiple/disabled/upload event.
- `FormDropdownMenuFilter.test.ts` (8) — option rendering, v-model
update on click, single-option disabled state, import-button gating by
\`useModelUpload.isUploadButtonEnabled\` (mocked), \`showUploadDialog\`
invocation.
- `FormDropdownMenuItem.test.ts` (10) — label vs name preference,
img/video rendering by injected \`AssetKindKey\`, placeholder gradient,
list-small layout, click emits index, mediaLoad event, selection
indicator.

## Review Focus

- \`useModelUpload\` mocked at the module boundary with a dynamic import
of \`vue\` inside \`vi.mock\` (needed because \`vi.hoisted\` runs before
imports).
- \`AssetKindKey\` provided via \`global.provide\` using the
\`ComputedRef<AssetKind>\` shape.
- \`v-tooltip\` registered as a no-op directive to avoid render errors
in happy-dom.
- No changes to any source component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11441-test-add-unit-tests-for-form-dropdown-internals-3486d73d3650813cb4a1c6568280ef1a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 00:36:25 +00:00
Terry Jia
b0fa179b87 refactor: extract Load3d render loop to load3dRenderLoop (#11623)
## Summary

Pull the `requestAnimationFrame` loop and its activity-gated tick body
out of `Load3d` into a small `startRenderLoop({ tick, isActive })`
helper. Pure mechanical refactor — no behavior change. First of four
small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dRenderLoop.ts` exports `startRenderLoop` (returns
a `{ stop }` handle). `Load3d.startAnimation()` now constructs a loop
through it; `Load3d.remove()` calls `stop()` instead of
`cancelAnimationFrame`. Field `animationFrameId: number | null` becomes
`renderLoop: RenderLoopHandle | null`.

## Review Focus

- The tick body inside `startAnimation()` is byte-identical to the
previous inline body — only the rAF scheduling has moved.
- `isActive()` is now invoked through a `() => this.isActive()` closure
instead of a direct call inside the inline `animate` function, so the
activity check still fires once per frame and reads the same fields.
- The new helper has 4 unit tests covering: ticks while active, skip
while inactive, stop halts ticks, stop is idempotent.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11623-refactor-extract-Load3d-render-loop-to-load3dRenderLoop-34d6d73d3650815c9c4ec7713e912e37)
by [Unito](https://www.unito.io)
2026-04-25 20:03:01 -04:00
Terry Jia
ba6dd2a09c refactor(load3d): extract viewport math to load3dViewport (#11624)
## Summary

Pull two pure helpers out of `Load3d` into a sibling module. Mechanical
refactor — no behavior change. Second of four small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dViewport.ts` exports two pure functions:
- `computeLetterboxedViewport({ width, height }, targetAspectRatio)`
returns `{ offsetX, offsetY, width, height }` — the aspect-ratio fitting
math that was duplicated inline in three places (`renderMainScene`,
`setBackgroundImage`, `handleResize`).
- `isLoad3dActive(flags)` consumes the activity-flag struct used by the
rAF tick gate; `Load3d.isActive()` now delegates to it.

## Review Focus

- The three call sites in `Load3d.ts` produce byte-identical viewport
rectangles for any `(containerWidth, containerHeight, targetAspect)`
triple — same branch on aspect-ratio comparison, same offset derivation.
Simplest way to verify: diff the math.
- `isLoad3dActive` ORs the same six flags as before in the same order.
Old implementation read `this.STATUS_MOUSE_ON_NODE` etc. directly; new
one reads them through a flags object built at the call site.
- 13 unit tests cover the helpers: the "wider" / "taller" / "matching"
aspect cases for letterboxing, and each individual flag flipping
`isLoad3dActive` on by itself.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11624-refactor-load3d-extract-viewport-math-to-load3dViewport-34d6d73d365081ab9af5fc445bf5bd5a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 17:48:44 -04:00
Kelly Yang
4a9001f675 test: add E2E tests for image crop widget Levels 4-7 (#11571)
## Summary

Adds 12 Playwright E2E tests for the `ImageCropV2` widget covering
aspect ratio selection, lock/unlock behavior, constrained resize, and
BoundingBox numeric input — all of which had zero test coverage.

## Changes

**Level 4 — Aspect Ratio Selection** (`with source image after
execution`)
- Selecting 16:9 preset adjusts crop height proportionally via
`applyLockedRatio`
- Selecting Custom unlocks the ratio and restores all 8 resize handles

**Level 5 — Lock/Unlock** (`without source image` + `with source image
after execution`)
- Selecting a preset auto-enables the lock (aria-label changes to
"Unlock aspect ratio")
- Unlocking after a preset reverts the dropdown display to "Custom"
- Full lock→unlock round-trip verifies handle count (4 → 8) and
aria-label on both transitions

**Level 6 — Constrained Resize** (`with source image after execution`)
- NW corner drag grows origin (x, y decrease) and dimensions while
maintaining ratio
- SE corner drag beyond image edge clamps to boundary
- NW corner drag beyond (0, 0) clamps x/y to image boundary
- Inward SE corner drag enforces `MIN_CROP_SIZE` (16px minimum)

**Level 7 — BoundingBox Numeric Input** (`with source image after
execution`)
- X increment button increments crop x by 1
- Width increment button increments crop width by 1
- BoundingBox inputs reflect updated position after a drag

No source code was modified.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E coverage plus adding
`data-testid` attributes to BoundingBox inputs, with no behavioral logic
changes expected.
> 
> **Overview**
> **Expands E2E coverage for the `ImageCropV2` widget** by adding new
Playwright tests for ratio preset selection, lock/unlock behavior,
constrained resizing (including boundary clamping and min size), and
BoundingBox numeric input updates.
> 
> **Improves testability of BoundingBox controls** by adding
`data-testid` attributes to the `WidgetBoundingBox.vue`
`ScrubableNumberInput` fields (`x`, `y`, `width`, `height`) so E2E tests
can target increment/input elements reliably.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b008f42942. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11571-test-add-E2E-tests-for-image-crop-widget-Levels-4-7-34b6d73d365081c79118ca9ae08f291c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 00:17:12 -04:00
Dante
9cf035879f test: add WidgetCurve unit tests (#11469)
## Summary

Splits the WidgetCurve test coverage out of #11446 so this widget can be
reviewed independently.

## Changes

- **What**: Adds WidgetCurve unit tests covering point forwarding,
interpolation updates, disabled-state behavior, and upstream value
handling.

## Review Focus

Focused test-only PR extracted from #11446.
Validated with `pnpm test:unit -- --run
src/components/curve/WidgetCurve.test.ts`.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11469-test-add-WidgetCurve-unit-tests-3486d73d365081c2a68bc8403fa0265f)
by [Unito](https://www.unito.io)
2026-04-24 17:47:54 -07:00
Alexander Brown
d0e9984a73 feat: update BYOKeySection images to enterprise node WebPs (#11614)
Update BYOKeySection card images from placeholder API logos to dedicated
enterprise node WebP images hosted on media.comfy.org.

## Changes
- Replace `logo-purple.webp` and `logo-yellow.webp` with
`enterprise_node_1.webp` and `enterprise_node_2.webp`
- New images are near-lossless WebP (~70% smaller than source PNGs)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11614-feat-update-BYOKeySection-images-to-enterprise-node-WebPs-34c6d73d365081239d92c649eb563b7e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 20:03:21 +00:00
pythongosssss
125c11b3d0 fix: translate blueprint label (#11573)
## Summary

Fixes hardcoded "Blueprint" text and adds e2e coverage to test
visibility, resolving #11473

## Changes

- **What**: 
- add translation
- add test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11573-fix-translate-blueprint-label-34b6d73d365081009215e06be6aa1fa0)
by [Unito](https://www.unito.io)
2026-04-24 19:27:47 +00:00
Alexander Brown
725ed120e8 fix: disable parallax on mobile to prevent enterprise section overlap (#11609)
## Summary

Disable parallax on mobile in the enterprise DataOwnershipSection to
prevent images from overlapping the next section.

## Changes

- **What**: Add `mediaQuery` option to `useParallax` composable, using
GSAP's `matchMedia()` to create/revert animations responsively.
DataOwnershipSection now only applies parallax at the `lg` (1024px+)
breakpoint.

## Review Focus

GSAP `matchMedia` automatically reverts animations (resetting
transforms) when the query stops matching, so no manual cleanup is
needed on resize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11609-fix-disable-parallax-on-mobile-to-prevent-enterprise-section-overlap-34c6d73d365081a48d55e4cf880e3bab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:13:00 -07:00
Alexander Brown
453a0edd1e chore: refresh Ashby careers snapshot (#11611)
## Changes

Refreshes the Ashby careers snapshot (`ashby-roles.snapshot.json`).

The "Business" department has been renamed to "Operations" on Ashby's
side. The same 3 roles (Senior Technical Recruiter, BizOps Strategist,
Founding Customer Success Manager) now appear under "Operations".

## Testing

- Snapshot was generated via `pnpm --filter @comfyorg/website
ashby:refresh-snapshot` which validates the API response through Zod
schemas before writing.
- Lint-staged checks (typecheck, eslint, oxlint, oxfmt) passed on
commit.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11611-chore-refresh-Ashby-careers-snapshot-34c6d73d3650813ea289c3c0371f882b)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:11:13 -07:00
Christian Byrne
5a8ded7959 Website: pull careers page listing from ashby API (#11590)
Careers

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-24 11:51:43 -07:00
Alexander Brown
bb23b9352c fix: update enterprise hero SVG to match updated design (#11608)
## Changes

Update the enterprise hero SVG in `HeroSection.vue` to match the updated
design export.

### Key changes

- **viewBox**: `600 -50 1000 1100` → `0 0 1600 1046` with `clip-path`
wrapper and background rects for proper clipping
- **Block pieces**: stroke/stroke-width moved from parent `<g>` to each
individual `<path>`, with duplicated paths for layered rendering
- **Block cluster**: wrapped in `.block-cluster` group with its own CSS
transform-origin
- **Ripple delay classes**: renamed `ripple-delay-*` → `delay-*`
- **Fade overlay**: added `pointer-events: none` to prevent blocking
interactions

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 10:35:24 -07:00
Alexander Brown
25f0b41f63 Remember what was forgotten (#11603)
## Summary

Every page has a story to tell.

## Changes

- **What**: Something was missing. Now it isn't.

## Review Focus

Look closely at what was lost.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11603-Remember-what-was-forgotten-34c6d73d36508184b3cef39d0be4a3bd)
by [Unito](https://www.unito.io)
2026-04-24 10:28:32 -07:00
Alexander Brown
e7673fcca7 update: API page links to keys and docs (#11606)
## Summary

Update API page CTA buttons to link directly to the API keys page and
API docs instead of generic platform/cloud/docs links.

## Changes

- **What**: Point "Get API Keys" buttons in HeroSection and StepsSection
to `platform.comfy.org/profile/api-keys`; point "Read the Docs" button
to `docsApi` route; add `apiKeys` entry to `externalLinks` config.

## Review Focus

Straightforward link target updates — verify the new URLs are correct.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11606-update-API-page-links-to-keys-and-docs-34c6d73d365081268bb5dd55c33646c8)
by [Unito](https://www.unito.io)
2026-04-24 10:28:03 -07:00
122 changed files with 11382 additions and 784 deletions

View File

@@ -46,3 +46,9 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
# Ashby (apps/website careers page build).
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
# WEBSITE_ASHBY_API_KEY=
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org

View File

@@ -2,6 +2,7 @@ dist/
.astro/
test-results/
playwright-report/
results.json
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

123
apps/website/README.md Normal file
View File

@@ -0,0 +1,123 @@
# @comfyorg/website
Marketing/brand website built with Astro + Vue.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
API at build time. Data flow:
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
Astro build.
2. `src/utils/ashby.ts` calls
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
validates the envelope and each posting with Zod
(`src/utils/ashby.schema.ts`), and maps to the domain type in
`src/data/roles.ts`.
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
the fetcher falls back to the committed JSON snapshot at
`src/data/ashby-roles.snapshot.json`.
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
builds.
### Required environment variables
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
inline that into the client bundle).
| Name | Purpose | Default (when unset) |
| ------------------------------ | --------------------------- | --------------------------------- |
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
### CI wiring (manual step — required)
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
GitHub App. A maintainer must apply the following edits **once**:
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
build step and run the unit tests before it:
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run website unit tests
run: pnpm --filter @comfyorg/website test:unit
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
run: pnpm --filter @comfyorg/website build
- name: Verify API key is not leaked into build output
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
run: |
set +x
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
echo "Secret not available in this run; skipping leak check."
exit 0
fi
# grep -rlF prints only file paths (never match content).
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
| tr '\0' '\n' || true)
if [ -n "$MATCHES" ]; then
echo "::error title=Ashby API key leaked into build output::$MATCHES"
exit 1
fi
```
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
two env vars to the top-level `env:` block so `vercel build` (both
`deploy-preview` and `deploy-production` jobs) sees them:
```yaml
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
```
The secret must also be added to the Vercel project environment
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
that `vercel build` in the preview job has access to it.
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
before the build runs. Fork-safe PR interactions (the preview-URL
comment) are handled by `pr-vercel-website-preview.yaml`.
### Refreshing the snapshot
When a maintainer wants to update the committed snapshot (e.g. after
onboarding/offboarding roles):
```bash
WEBSITE_ASHBY_API_KEY=WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
pnpm --filter @comfyorg/website ashby:refresh-snapshot
git commit apps/website/src/data/ashby-roles.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Scripts
- `pnpm dev` — Astro dev server
- `pnpm build` — production build to `dist/`
- `pnpm typecheck``astro check`
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot

View File

@@ -7,6 +7,12 @@ export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Careers page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/careers')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers — Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Roles', level: 2 })
).toBeVisible()
})
test('renders at least one role from the snapshot', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
await expect(roles.first()).toBeVisible()
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 — Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
})
})

View File

@@ -9,10 +9,13 @@
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots"
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
@@ -23,7 +26,8 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:"
"vue": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
@@ -32,7 +36,9 @@
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
@@ -89,6 +95,22 @@
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [

View File

@@ -0,0 +1,33 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchRolesForBuild } from '../src/utils/ashby'
const snapshotPath = fileURLToPath(
new URL('../src/data/ashby-roles.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchRolesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
const totalRoles = outcome.snapshot.departments.reduce(
(n, d) => n + d.roles.length,
0
)
process.stdout.write(
`Wrote snapshot with ${totalRoles} role(s) to ${snapshotPath}\n`
)

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const investors = [
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
{ name: 'ABSTRACT', icon: '/icons/investors/abstract.svg' },
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.story.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.story.headingBefore', locale)
}}<span class="text-primary-comfy-yellow">{{
t('about.story.headingHighlight', locale)
}}</span
>{{ t('about.story.headingAfter', locale) }}
</h2>
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
{{ t('about.story.body', locale) }}
</p>
</div>
<!-- Investor card -->
<div
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
>
<div class="inline-flex items-center">
<!-- OUR badge (shorter) -->
<div class="relative z-10 flex h-9 items-center">
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
>
OUR
</span>
</div>
<!-- Union connector (overlaps both badges to eliminate seams) -->
<img
src="/icons/node-union-2size-reverse.svg"
alt=""
class="relative z-20 -mx-px h-12 w-auto"
/>
<!-- INVESTORS badge (taller) -->
<div class="relative z-10 flex h-12 items-center">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
>
INVESTORS
</span>
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
</div>
</div>
<p
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
>
{{ t('about.story.investorsBody', locale) }}
</p>
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
<div
v-for="investor in investors"
:key="investor.name"
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
>
<img
:src="investor.icon"
:alt="investor.name"
class="max-h-8 w-auto"
/>
</div>
</div>
</div>
<!-- Quote card -->
<div
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
>
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
{{ t('about.quote.text', locale) }}
</p>
<p
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
>
{{ t('about.quote.attribution', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -1,121 +1,42 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { locale = 'en', departments = [] } = defineProps<{
locale?: Locale
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
interface Role {
title: string
department: string
location: string
id: string
}
interface Department {
name: string
key: string
roles: Role[]
}
const departments: Department[] = [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
id: 'abc787b9-ad85-421c-8218-debd23bea096'
},
{
title: 'Software Engineer',
department: 'Engineering',
location: 'San Francisco',
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
},
{
title: 'Product Manager',
department: 'Engineering',
location: 'London, UK',
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
},
{
title: 'Tech Lead Manager, Frontend',
department: 'Engineering',
location: 'San Francisco',
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
}
]
},
{
name: 'DESIGN',
key: 'design',
roles: [
{
title: 'Creative Director',
department: 'Design',
location: 'San Francisco',
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
},
{
title: 'Graphic Designer',
department: 'Design',
location: 'London, UK',
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
},
{
title: 'Freelance Motion Designer',
department: 'Design',
location: 'Remote',
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
}
]
},
{
name: 'MARKETING',
key: 'marketing',
roles: [
{
title: 'Lifecycle Growth Marketer',
department: 'Marketing',
location: 'San Francisco',
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
},
{
title: 'Graphic Designer',
department: 'Marketing',
location: 'London, UK',
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
}
]
}
]
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...departments.map((d) => ({ label: d.name, value: d.key }))
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? departments
: departments.filter((d) => d.key === activeCategory.value)
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
</script>
<template>
<section class="px-6 py-20 md:px-20 md:py-32">
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<!-- Left sidebar -->
<div class="shrink-0 md:w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
@@ -126,6 +47,7 @@ const filteredDepartments = computed(() =>
{{ t('careers.roles.heading', locale) }}
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
class="mt-4"
@@ -133,8 +55,15 @@ const filteredDepartments = computed(() =>
</div>
</div>
<!-- Role listings -->
<div class="min-w-0 flex-1">
<p
v-if="!hasRoles"
class="text-primary-warm-gray text-base md:text-lg"
data-testid="careers-roles-empty"
>
{{ t('careers.roles.empty', locale) }}
</p>
<div
v-for="dept in filteredDepartments"
:key="dept.key"
@@ -147,10 +76,11 @@ const filteredDepartments = computed(() =>
<a
v-for="role in dept.roles"
:key="role.id"
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
:href="role.applyUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
data-testid="careers-role-link"
>
<div class="min-w-0">
<span

View File

@@ -223,7 +223,7 @@ onUnmounted(() => {
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
<BrandButton
:href="externalLinks.platform"
:href="externalLinks.apiKeys"
size="lg"
class="text-center lg:min-w-60 lg:p-4"
>

View File

@@ -13,13 +13,13 @@ const steps = [
number: '01',
titleKey: 'api.steps.step1.title' as const,
descriptionKey: 'api.steps.step1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
number: '02',
titleKey: 'api.steps.step2.title' as const,
descriptionKey: 'api.steps.step2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
},
{
number: '03',
@@ -61,7 +61,7 @@ const steps = [
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<BrandButton
:href="externalLinks.cloud"
:href="externalLinks.apiKeys"
variant="solid"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"
@@ -69,7 +69,7 @@ const steps = [
{{ t('api.hero.getApiKeys', locale) }}
</BrandButton>
<BrandButton
:href="externalLinks.docs"
:href="externalLinks.docsApi"
variant="outline"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"

View File

@@ -10,12 +10,12 @@ const cards = [
{
titleKey: 'enterprise.byoKey.card1.title' as const,
descriptionKey: 'enterprise.byoKey.card1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
titleKey: 'enterprise.byoKey.card2.title' as const,
descriptionKey: 'enterprise.byoKey.card2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
}
]
</script>

View File

@@ -16,9 +16,11 @@ const midRightRef = ref<HTMLElement>()
const bottomLeftRef = ref<HTMLElement>()
const bottomRightRef = ref<HTMLElement>()
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
const parallaxOpts = { trigger: sectionRef, mediaQuery: '(min-width: 1024px)' }
useParallax([topLeftRef, topRightRef], { ...parallaxOpts, y: 200 })
useParallax([midLeftRef, midRightRef], { ...parallaxOpts, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { ...parallaxOpts, y: 400 })
</script>
<template>

View File

@@ -44,162 +44,303 @@ onMounted(() => {
<svg
ref="svgRef"
class="block size-full"
viewBox="600 -50 1000 1100"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<!-- Ripple rings -->
<path
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<g clip-path="url(#enterpriseHeroClip)">
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
height="800"
transform="translate(712 112)"
fill="#211927"
/>
<!-- Exploding block cluster -->
<g stroke="#4D3762" stroke-width="2">
<!-- Ripple rings -->
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
class="ripple-path delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
class="ripple-path delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
class="ripple-path delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.63 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.91 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
<!-- Exploding block cluster -->
<g class="block-cluster">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.372L1175.19 482.387C1175.19 475.211 1170.16 466.485 1163.94 462.899L1117.21 435.919C1113.28 433.647 1106.2 433.752 1102.27 436.025L1055.52 463.03C1049.31 466.62 1044.27 475.347 1044.27 482.524L1044.25 536.508C1044.25 541.052 1047.7 547.23 1051.64 549.502L1098.37 576.482C1104.58 580.069 1114.65 580.066 1120.87 576.476L1167.61 549.472C1171.55 547.194 1175.17 540.924 1175.17 536.372Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.2 528.1C1098.2 520.924 1093.16 512.198 1086.95 508.612L1040.22 481.632C1036.29 479.36 1029.21 479.465 1025.28 481.738L978.532 508.743C972.318 512.333 967.279 521.06 967.277 528.237L967.263 582.221C967.261 586.765 970.709 592.943 974.644 595.215L1021.37 622.195C1027.59 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.41 528.1C1125.4 520.924 1130.44 512.198 1136.65 508.612L1183.38 481.632C1187.32 479.36 1194.39 479.465 1198.32 481.738L1245.07 508.743C1251.28 512.333 1256.32 521.06 1256.32 528.237L1256.34 582.221C1256.34 586.765 1252.89 592.943 1248.96 595.215L1202.23 622.195C1196.01 625.782 1185.94 625.779 1179.73 622.189L1132.98 595.184C1129.04 592.907 1125.42 586.637 1125.42 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.173L1045.25 573.188C1045.25 566.012 1050.28 557.286 1056.49 553.7L1103.22 526.72C1107.16 524.448 1114.23 524.553 1118.17 526.826L1164.91 553.831C1171.13 557.42 1176.17 566.148 1176.17 573.325L1176.18 627.309C1176.18 631.853 1172.74 638.031 1168.8 640.303L1122.07 667.283C1115.86 670.87 1105.79 670.867 1099.57 667.277L1052.83 640.272C1048.88 637.995 1045.26 631.725 1045.26 627.173Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.630 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.910 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
style="pointer-events: none"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
/>
<defs>
<linearGradient
id="enterpriseHeroFade"
@@ -212,6 +353,9 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>
</svg>
</div>
@@ -255,13 +399,13 @@ onMounted(() => {
animation: ripple-effect 4s linear infinite;
}
.ripple-delay-1 {
.delay-1 {
animation-delay: -1s;
}
.ripple-delay-2 {
.delay-2 {
animation-delay: -2s;
}
.ripple-delay-3 {
.delay-3 {
animation-delay: -3s;
}
@@ -281,6 +425,11 @@ onMounted(() => {
}
}
.block-cluster {
transform-origin: center;
transform-box: fill-box;
}
.block-piece {
transform-origin: center;
transform-box: fill-box;

View File

@@ -19,6 +19,8 @@ interface ParallaxOptions {
start?: string
/** ScrollTrigger end value (default: 'bottom top') */
end?: string
/** Media query string — animation only runs when matched (responsive) */
mediaQuery?: string
}
export function useParallax(
@@ -26,24 +28,27 @@ export function useParallax(
options: ParallaxOptions = {}
) {
const { fromY = 0, y = 200 } = options
let ctx: gsap.Context | undefined
let ctx: gsap.Context | gsap.MatchMedia | undefined
onMounted(() => {
if (prefersReducedMotion()) return
const triggerEl = options.trigger?.value
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length || prefersReducedMotion()) return
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
const createAnimations = () => {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length) return
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
ctx = gsap.context(() => {
els.forEach((el) => {
gsap.fromTo(
el,
@@ -51,7 +56,15 @@ export function useParallax(
{ y: resolve(y, el, trigger), ease: 'none', scrollTrigger }
)
})
})
}
if (options.mediaQuery) {
const mm = gsap.matchMedia()
mm.add(options.mediaQuery, createAnimations)
ctx = mm
} else {
ctx = gsap.context(createAnimations)
}
})
onUnmounted(() => {

View File

@@ -27,6 +27,7 @@ export function getRoutes(locale: Locale = 'en'): Routes {
}
export const externalLinks = {
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
discord: 'https://discord.com/invite/comfyorg',

View File

@@ -0,0 +1,169 @@
{
"fetchedAt": "2026-04-24T18:59:03.989Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "e915f2c78b17f93b",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
},
{
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "547b6ba622c800a5",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
},
{
"id": "7bb02634a24763bc",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
}
]
},
{
"name": "ENGINEERING",
"key": "engineering",
"roles": [
{
"id": "102d58e35a8a9817",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
},
{
"id": "d01d69fba7743905",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
},
{
"id": "f36f60cfd5bb5910",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
},
{
"id": "9d8ec4c65e20b19e",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
},
{
"id": "be94b193d1f4d482",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
},
{
"id": "ab48f5db6bd1783c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
},
{
"id": "c5dff4ee628bdcd1",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
},
{
"id": "4302a7aaa87e16e3",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
}
]
},
{
"name": "MARKETING",
"key": "marketing",
"roles": [
{
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
},
{
"id": "130d7218d7895bdb",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
}
]
},
{
"name": "OPERATIONS",
"key": "operations",
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Senior Technical Recruiter",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
},
{
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
}
]
}
]
}

View File

@@ -0,0 +1,18 @@
export interface Role {
id: string
title: string
department: string
location: string
applyUrl: string
}
export interface Department {
name: string
key: string
roles: Role[]
}
export interface RolesSnapshot {
fetchedAt: string
departments: Department[]
}

View File

@@ -1505,6 +1505,10 @@ const translations = {
// CareersRolesSection
'careers.roles.heading': { en: 'Roles', 'zh-CN': '职位' },
'careers.roles.empty': {
en: 'No open roles right now. Check back soon.',
'zh-CN': '目前暂无开放职位,请稍后再来查看。'
},
// CareersFAQSection
'careers.faq.heading': { en: 'Q&A', 'zh-CN': 'Q&A' },

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/about/HeroSection.vue'
import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../components/about/CareersSection.vue'
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />
<ValuesSection client:visible />
<CareersSection />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../components/careers/RolesSection.vue'
import WhyJoinSection from '../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../components/common/FAQSection.vue'
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
>
<HeroSection />
<RolesSection client:visible />
<RolesSection departments={departments} client:visible />
<WhyJoinSection client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/about/HeroSection.vue'
import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../../components/about/CareersSection.vue'
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />
<ValuesSection locale="zh-CN" client:visible />
<CareersSection locale="zh-CN" />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../../components/careers/RolesSection.vue'
import WhyJoinSection from '../../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../../components/common/FAQSection.vue'
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" client:visible />
<RolesSection locale="zh-CN" departments={departments} client:visible />
<WhyJoinSection locale="zh-CN" client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -0,0 +1,130 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './ashby'
import type { RolesSnapshot } from '../data/roles'
import { reportAshbyOutcome, resetAshbyReporterForTests } from './ashby.ci'
function baseSnapshot(): RolesSnapshot {
return {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedRoles:
droppedCount === 0
? []
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
snapshot: {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
}
describe('reportAshbyOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetAshbyReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'ashby-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a clean fresh outcome', () => {
reportAshbyOutcome(freshOutcome(0))
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one set of annotations across repeated calls', () => {
reportAshbyOutcome(freshOutcome(1))
reportAshbyOutcome(freshOutcome(1))
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby: dropped 1 invalid')
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
})
it('emits ::error for auth failures in a stale outcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby authentication failed')
})
it('emits ::warning for missing-env stale outcomes', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby integration')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
})
})

View File

@@ -0,0 +1,113 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './ashby'
let hasReported = false
export function resetAshbyReporterForTests(): void {
hasReported = false
}
export function reportAshbyOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
const drops = outcome.droppedRoles
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
.join('%0A')
return [
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
]
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Ashby fetch failed and no snapshot is available::Cannot build careers page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website ashby:refresh-snapshot\` locally with a valid WEBSITE_ASHBY_API_KEY.%0A 2. Commit apps/website/src/data/ashby-roles.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
}
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 💼 Careers (Ashby)\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from Ashby)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Dropped', String(outcome.droppedCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — Ashby fetch failed)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -0,0 +1,17 @@
import { z } from 'zod'
export const AshbyJobPostingSchema = z.object({
title: z.string().min(1),
department: z.string().optional(),
location: z.string().optional(),
isListed: z.boolean(),
jobUrl: z.string().url(),
applyUrl: z.string().url().optional()
})
export const AshbyJobBoardResponseSchema = z.object({
apiVersion: z.literal('1'),
jobs: z.array(z.unknown())
})
export type AshbyJobPosting = z.infer<typeof AshbyJobPostingSchema>

View File

@@ -0,0 +1,328 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AshbyJobPosting } from './ashby.schema'
import type { RolesSnapshot } from '../data/roles'
import { fetchRolesForBuild, resetAshbyFetcherForTests } from './ashby'
const BASE_URL = 'https://ashby.test'
const BOARD = 'comfy-org'
const KEY = 'abc-123-secret'
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
return {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
isListed: true,
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer/apply',
...overrides
}
}
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(roleCount = 2): RolesSnapshot {
const roles = Array.from({ length: roleCount }, (_, i) => ({
id: `snapshot-role-${i}`,
title: `Snapshot Role ${i}`,
department: 'Engineering',
location: 'San Francisco',
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
departments: [{ name: 'ENGINEERING', key: 'engineering', roles }]
}
}
function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
const file = join(dir, 'ashby-roles.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchRolesForBuild', () => {
const savedApiKey = process.env.WEBSITE_ASHBY_API_KEY
const savedBoardName = process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
beforeEach(() => {
resetAshbyFetcherForTests()
delete process.env.WEBSITE_ASHBY_API_KEY
delete process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
})
afterEach(() => {
vi.restoreAllMocks()
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
})
it('returns fresh when the API succeeds', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.departments).toHaveLength(1)
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
/design-engineer\/apply$/
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
)
})
it('drops invalid roles individually and keeps the valid ones', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(1)
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments).toEqual([])
expect(outcome.droppedCount).toBe(0)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('normalizes missing department and location to safe defaults', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const job = validJob()
delete (job as Record<string, unknown>).department
delete (job as Record<string, unknown>).location
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const [department] = outcome.snapshot.departments
expect(department?.name).toBe('OTHER')
expect(department?.roles[0]?.location).toBe('Remote')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('filters out roles with isListed=false', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const titles = outcome.snapshot.departments.flatMap((d) =>
d.roles.map((r) => r.title)
)
expect(titles).not.toContain('Hidden')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns stale with missing env when the snapshot is present', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const fetchImpl = vi.fn()
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^missing /)
expect(fetchImpl).not.toHaveBeenCalled()
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when both env and snapshot are missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on HTTP 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('falls back to snapshot on envelope schema mismatch', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^envelope schema/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const opts = {
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
}
const [a, b] = await Promise.all([
fetchRolesForBuild(opts),
fetchRolesForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('never writes to the snapshot file on success', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const before = new URL(snapshotUrl.href)
const fs = await import('node:fs')
const initial = fs.readFileSync(before).toString()
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
const after = fs.readFileSync(before).toString()
expect(after).toBe(initial)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on 4xx auth failures for 403', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,299 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import type { AshbyJobPosting } from './ashby.schema'
import type { Department, Role, RolesSnapshot } from '../data/roles'
import bundledSnapshot from '../data/ashby-roles.snapshot.json' with { type: 'json' }
import {
AshbyJobBoardResponseSchema,
AshbyJobPostingSchema
} from './ashby.schema'
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
title: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: RolesSnapshot
droppedCount: number
droppedRoles: DroppedRole[]
}
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchRolesOptions {
apiKey?: string
boardName?: string
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
export function resetAshbyFetcherForTests(): void {
inflight = undefined
}
export function fetchRolesForBuild(
options: FetchRolesOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchRolesForBuild(options)
return inflight
}
async function doFetchRolesForBuild(
options: FetchRolesOptions
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
const boardName =
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
if (!apiKey || !boardName) {
return fallback(
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
options.snapshotUrl
)
}
const result = await tryFetchAndParse(apiKey, boardName, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
departments: result.departments
},
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
departments: Department[]
droppedRoles: DroppedRole[]
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
apiKey: string,
boardName: string,
options: FetchRolesOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl}/posting-api/job-board/${encodeURIComponent(
boardName
)}?includeCompensation=false`
const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, authHeader, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const envelope = AshbyJobBoardResponseSchema.safeParse(response.body)
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
return parseRoles(envelope.data.jobs)
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
authHeader: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: {
Authorization: authHeader,
Accept: 'application/json; version=1'
},
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
function parseRoles(jobs: readonly unknown[]): FetchOk {
const valid: AshbyJobPosting[] = []
const droppedRoles: DroppedRole[] = []
for (const raw of jobs) {
const parsed = AshbyJobPostingSchema.safeParse(raw)
if (!parsed.success) {
droppedRoles.push({
title: extractTitle(raw),
reason: parsed.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')
})
continue
}
if (!parsed.data.isListed) continue
valid.push(parsed.data)
}
return { kind: 'ok', departments: groupByDepartment(valid), droppedRoles }
}
function extractTitle(raw: unknown): string {
if (
raw !== null &&
typeof raw === 'object' &&
'title' in raw &&
typeof (raw as { title: unknown }).title === 'string'
) {
return (raw as { title: string }).title
}
return ''
}
const DEFAULT_DEPARTMENT = 'Other'
const DEFAULT_LOCATION = 'Remote'
function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
const byKey = new Map<string, Department>()
for (const job of jobs) {
const displayDepartment = normalizeDepartment(job.department)
const name = displayDepartment.toUpperCase()
const key = slugify(name)
const existing = byKey.get(key)
const role = toDomainRole(job, displayDepartment)
if (existing) {
existing.roles.push(role)
} else {
byKey.set(key, { name, key, roles: [role] })
}
}
return [...byKey.values()].sort((a, b) => a.name.localeCompare(b.name))
}
function toDomainRole(job: AshbyJobPosting, department: string): Role {
const applyUrl = job.applyUrl ?? job.jobUrl
return {
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
title: job.title,
department: capitalize(department),
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
applyUrl
}
}
function normalizeDepartment(raw: string | undefined): string {
const trimmed = (raw ?? '').trim()
return trimmed.length > 0 ? trimmed : DEFAULT_DEPARTMENT
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<RolesSnapshot | null> {
if (!snapshotUrl) {
return isRolesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isRolesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function isRolesSnapshot(value: unknown): value is RolesSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
return (
typeof candidate.fetchedAt === 'string' &&
Array.isArray(candidate.departments)
)
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -8,8 +8,10 @@
"include": [
"src/**/*",
"e2e/**/*",
"scripts/**/*",
"astro.config.ts",
"playwright.config.ts"
"playwright.config.ts",
"vitest.config.ts"
],
"exclude": ["src/**/*.stories.ts"],
"references": [{ "path": "./tsconfig.stories.json" }]

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.{test,spec}.ts'],
globals: false
}
})

View File

@@ -250,6 +250,26 @@ export class ModelLibrarySidebarTab extends SidebarTab {
}
}
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
type MediaFilterLabel = 'Image' | 'Video' | 'Audio' | '3D'
function getMediaFilterLabel(
filter: MediaFilterKind | MediaFilterLabel
): MediaFilterLabel {
switch (filter) {
case 'image':
return 'Image'
case 'video':
return 'Video'
case 'audio':
return 'Audio'
case '3d':
return '3D'
default:
return filter
}
}
export class AssetsSidebarTab extends SidebarTab {
// --- Tab navigation ---
public readonly generatedTab: Locator
@@ -261,6 +281,13 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
public readonly filterButton: Locator
// --- Filter menu checkboxes (cloud-only, shown inside filter popover) ---
public readonly filterImageCheckbox: Locator
public readonly filterVideoCheckbox: Locator
public readonly filterAudioCheckbox: Locator
public readonly filter3DCheckbox: Locator
// --- View mode ---
public readonly listViewOption: Locator
@@ -269,6 +296,8 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Sort options (cloud-only, shown inside settings popover) ---
public readonly sortNewestFirst: Locator
public readonly sortOldestFirst: Locator
public readonly sortLongestFirst: Locator
public readonly sortFastestFirst: Locator
// --- Asset cards ---
public readonly assetCards: Locator
@@ -299,10 +328,17 @@ export class AssetsSidebarTab extends SidebarTab {
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.filterButton = page.getByRole('button', { name: 'Filter by' })
this.filterImageCheckbox = page.getByRole('checkbox', { name: 'Image' })
this.filterVideoCheckbox = page.getByRole('checkbox', { name: 'Video' })
this.filterAudioCheckbox = page.getByRole('checkbox', { name: 'Audio' })
this.filter3DCheckbox = page.getByRole('checkbox', { name: '3D' })
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.sortNewestFirst = page.getByText('Newest first')
this.sortOldestFirst = page.getByText('Oldest first')
this.sortLongestFirst = page.getByText('Generation time (longest first)')
this.sortFastestFirst = page.getByText('Generation time (fastest first)')
this.assetCards = page
.getByRole('button')
.and(page.locator('[data-selected]'))
@@ -334,6 +370,12 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText(title)
}
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
return this.page.getByRole('checkbox', {
name: getMediaFilterLabel(filter)
})
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
}
@@ -383,6 +425,29 @@ export class AssetsSidebarTab extends SidebarTab {
.waitFor({ state: 'visible', timeout: 3000 })
}
async openFilterMenu() {
await this.dismissToasts()
await this.filterButton.click()
await this.filterCheckbox('Image').waitFor({
state: 'visible',
timeout: 3000
})
}
async toggleMediaTypeFilter(
filter: MediaFilterKind | MediaFilterLabel
): Promise<void> {
const checkbox = this.filterCheckbox(filter)
const before = await checkbox.getAttribute('aria-checked')
await checkbox.click()
const expected = before === 'true' ? 'false' : 'true'
await expect(checkbox).toHaveAttribute('aria-checked', expected)
}
async getAssetCardOrder(): Promise<string[]> {
return await this.assetCards.allInnerTexts()
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })

View File

@@ -10,6 +10,8 @@ export class SubgraphBreadcrumbPanel {
readonly activeItem: Locator
readonly missingNodesIcon: Locator
readonly blueprintTag: Locator
readonly rootItem: Locator
readonly rootBlueprintTag: Locator
constructor(public readonly page: Page) {
this.root = page.getByTestId(TestIds.breadcrumb.subgraph)
@@ -23,10 +25,10 @@ export class SubgraphBreadcrumbPanel {
TestIds.breadcrumb.missingNodesIcon
)
this.blueprintTag = this.root.getByTestId(TestIds.breadcrumb.blueprintTag)
}
rootItem(): Locator {
return this.page.getByTestId(TestIds.breadcrumb.item('root'))
this.rootItem = page.getByTestId(TestIds.breadcrumb.item('root'))
this.rootBlueprintTag = this.rootItem.getByTestId(
TestIds.breadcrumb.blueprintTag
)
}
subgraphItem(subgraphId: string): Locator {

View File

@@ -7,26 +7,56 @@ const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
/**
* Media kinds supported by the assets sidebar filter UI. The string values
* match what the backend stores on `preview_output.mediaType` (`images` is
* intentionally plural to match existing API conventions; the others are
* singular as emitted by `useMediaAssetGalleryStore`).
*
* The sidebar filter ultimately matches on the filename extension, so the
* fixture also picks an extension-appropriate filename for each kind.
*/
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
images: 'png',
video: 'mp4',
audio: 'wav',
'3D': 'glb'
}
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
overrides: Partial<RawJobListItem> & {
id: string
/**
* Optional shorthand to set both `preview_output.mediaType` and an
* extension-appropriate filename. Ignored when `preview_output` is also
* supplied via `overrides`.
*/
mediaKind?: MediaKindFixture
}
): RawJobListItem {
const { mediaKind, ...rest } = overrides
const now = Date.now()
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
const mediaType = mediaKind ?? 'images'
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5000,
preview_output: {
filename: `output_${overrides.id}.png`,
filename: `output_${rest.id}.${extension}`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
mediaType
},
outputs_count: 1,
priority: 0,
...overrides
...rest
}
}
@@ -54,6 +84,46 @@ export function createMockJobs(
)
}
/**
* Create one job per requested media kind, in the order supplied. Jobs share
* a stable timestamp ordering (newer first) so callers can rely on the result
* order when mediaType filters are inactive.
*/
export function createMixedMediaJobs(
kinds: MediaKindFixture[]
): RawJobListItem[] {
const now = Date.now()
return kinds.map((kind, i) =>
createMockJob({
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
mediaKind: kind,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000
})
)
}
/**
* Create jobs with explicit `(create_time, execution duration)` pairs so that
* sort assertions for newest/oldest and longest/fastest are unambiguous.
*
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
* equals `durationMs`. The first spec becomes id `job-001`, etc.
*/
export function createJobsWithExecutionTimes(
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
): RawJobListItem[] {
return specs.map((spec, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: spec.createTime,
execution_start_time: spec.createTime,
execution_end_time: spec.createTime + spec.durationMs
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']

View File

@@ -0,0 +1,232 @@
import { expect } from '@playwright/test'
import type {
Asset,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// The assets sidebar's media-type filter menu only renders in cloud mode
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
// We tag tests `@cloud` so they run against the cloud Playwright project,
// and register both `/api/assets` and `/api/jobs` route handlers as auto
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
// MediaAssetCard renders the filename *without* extension via
// getFilenameDetails(...).filename, so card-text matching uses the basename.
function expectCardText(index: number): string {
const filename = MIXED_JOBS[index]?.preview_output?.filename
if (!filename) {
throw new Error(
`MIXED_JOBS[${index}].preview_output.filename is missing — ` +
'createMixedMediaJobs contract changed.'
)
}
return filename.replace(/\.[^.]+$/, '')
}
const imageCardName = expectCardText(0)
const videoCardName = expectCardText(1)
const audioCardName = expectCardText(2)
const threeDCardName = expectCardText(3)
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
function makeJobsResponseBody() {
return {
jobs: MIXED_JOBS,
pagination: {
offset: 0,
limit: MIXED_JOBS.length,
total: MIXED_JOBS.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
}
const test = comfyPageFixture.extend<{
stubCloudAssets: void
stubJobs: void
stubInputFiles: void
}>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse([]))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubJobs: [
async ({ page }, use) => {
const pattern = /\/api\/jobs(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeJobsResponseBody())
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubInputFiles: [
async ({ page }, use) => {
const pattern = /\/internal\/files\/input(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
test('Filter menu opens and exposes all four media-type checkboxes', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await expect(tab.filterImageCheckbox).toBeVisible()
await expect(tab.filterVideoCheckbox).toBeVisible()
await expect(tab.filterAudioCheckbox).toBeVisible()
await expect(tab.filter3DCheckbox).toBeVisible()
for (const cb of [
tab.filterImageCheckbox,
tab.filterVideoCheckbox,
tab.filterAudioCheckbox,
tab.filter3DCheckbox
]) {
await expect(cb).toHaveAttribute('aria-checked', 'false')
}
})
test('Selecting only "Image" hides non-image assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
await expect(tab.getAssetCardByName(videoCardName)).toHaveCount(0)
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
})
test('Selecting only "Video" hides non-video assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('video')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
})
test('Selecting only "Audio" hides non-audio assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('audio')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
})
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('3d')
await expect(tab.assetCards).toHaveCount(1)
await expect(tab.getAssetCardByName(threeDCardName)).toBeVisible()
})
test('Multiple filters combine via OR (image + video)', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
await tab.toggleMediaTypeFilter('video')
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible()
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
await expect(tab.getAssetCardByName(audioCardName)).toHaveCount(0)
await expect(tab.getAssetCardByName(threeDCardName)).toHaveCount(0)
})
test('Unchecking the active filter restores previously hidden cards', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
await expect(tab.assetCards).toHaveCount(1)
await tab.toggleMediaTypeFilter('image')
// TODO(#11635): the 3D preview card does not remount after a filter
// toggle restores it (only image/video/audio reappear). Image, video,
// and audio cover the restoration path; once #11635 is fixed, add the
// 3D card back to this assertion list.
await expect(tab.getAssetCardByName(imageCardName)).toBeVisible({
timeout: 10_000
})
await expect(tab.getAssetCardByName(videoCardName)).toBeVisible()
await expect(tab.getAssetCardByName(audioCardName)).toBeVisible()
})
})

View File

@@ -0,0 +1,206 @@
import { expect } from '@playwright/test'
import type {
Asset,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
// The assets sidebar's sort options live inside the settings popover and are
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
// page first-loads with mocks already in place.
// Three jobs whose `(create_time, duration)` axes are intentionally
// misaligned so newest/oldest and longest/fastest sorts produce *different*
// orderings — preventing false-pass tests where one ordering accidentally
// satisfies another.
//
// spec create_time duration (ms)
// ----------------------------------------
// job-001 1000 5000 (oldest, mid duration)
// job-002 2000 10000 (mid age, longest)
// job-003 3000 3000 (newest, shortest)
const SORT_JOBS = createJobsWithExecutionTimes([
{ createTime: 1000, durationMs: 5000 },
{ createTime: 2000, durationMs: 10000 },
{ createTime: 3000, durationMs: 3000 }
])
// MediaAssetCard renders the filename *without* extension via
// getFilenameDetails(...).filename, so card-text matching uses the basename.
const NAME_BY_ID: Record<string, string> = {
'job-001': 'output_job-001',
'job-002': 'output_job-002',
'job-003': 'output_job-003'
}
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
function makeJobsResponseBody() {
return {
jobs: SORT_JOBS,
pagination: {
offset: 0,
limit: SORT_JOBS.length,
total: SORT_JOBS.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
}
const test = comfyPageFixture.extend<{
stubCloudAssets: void
stubJobs: void
stubInputFiles: void
}>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse([]))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubJobs: [
async ({ page }, use) => {
const pattern = /\/api\/jobs(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeJobsResponseBody())
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
],
stubInputFiles: [
async ({ page }, use) => {
const pattern = /\/internal\/files\/input(?:\?.*)?$/
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Assets sidebar - sort options', { tag: '@cloud' }, () => {
test('Settings menu exposes all four sort options in cloud mode', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await expect(tab.sortNewestFirst).toBeVisible()
await expect(tab.sortOldestFirst).toBeVisible()
await expect(tab.sortLongestFirst).toBeVisible()
await expect(tab.sortFastestFirst).toBeVisible()
})
test('Default order is newest first (descending create_time)', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
// Cards should appear in the order: job-003, job-002, job-001
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-001'])
})
test('"Oldest first" reverses the order', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortOldestFirst.click()
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-002'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
})
test('"Longest first" puts the slowest job at the top', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortLongestFirst.click()
// Expected: job-002 (10s), job-001 (5s), job-003 (3s)
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-002'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
})
test('"Fastest first" puts the quickest job at the top', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortFastestFirst.click()
// Expected: job-003 (3s), job-001 (5s), job-002 (10s)
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-003'])
await expect(tab.assetCards.nth(1)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-002'])
})
test('Sort persists when the search input is edited', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(SORT_JOBS.length)
await tab.openSettingsMenu()
await tab.sortOldestFirst.click()
// Type a query that matches all three jobs, then clear it; sort order
// must remain "oldest first".
await tab.searchInput.fill('output_job')
await tab.searchInput.fill('')
await expect(tab.assetCards.nth(0)).toContainText(NAME_BY_ID['job-001'])
await expect(tab.assetCards.nth(2)).toContainText(NAME_BY_ID['job-003'])
})
})

View File

@@ -772,3 +772,119 @@ test.describe('Assets sidebar - delete confirmation', () => {
await expect(tab.assetCards).toHaveCount(initialCount)
})
})
// ==========================================================================
// 12. Media type filter (cloud-only)
// ==========================================================================
const MIXED_MEDIA_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-image',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'photo.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-video',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2010,
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'video'
},
outputs_count: 1
}),
createMockJob({
id: 'job-audio',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3010,
preview_output: {
filename: 'track.mp3',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'audio'
},
outputs_count: 1
})
]
// Filter button is guarded by isCloud (compile-time). The cloud CI project
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
// supports authenticated comfyPage setup.
test.describe('Assets sidebar - media type filter', () => {
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MIXED_MEDIA_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Filter menu shows media type options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openFilterMenu()
await expect(tab.filterCheckbox('Image')).toBeVisible()
await expect(tab.filterCheckbox('Video')).toBeVisible()
await expect(tab.filterCheckbox('Audio')).toBeVisible()
await expect(tab.filterCheckbox('3D')).toBeVisible()
})
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = tab.assetCards
await expect(
initialCount,
'All three mixed-media jobs should render'
).toHaveCount(3)
// Open filter menu and enable only image filter (selecting a filter
// restricts to that type only, hiding unselected types)
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
// Only the image asset should remain
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
})
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Enable image filter to restrict to images only
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
// Uncheck image filter to remove all filters (restores all assets)
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
})

View File

@@ -251,7 +251,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(NESTED_WORKFLOW)
await enterNestedSubgraphs(comfyPage)
await expect(subgraphBreadcrumb.panel.rootItem()).toBeAttached()
await expect(subgraphBreadcrumb.panel.rootItem).toBeAttached()
await expect(
subgraphBreadcrumb.panel.subgraphItem(SUBGRAPH_2_ID)
).toContainText(SUBGRAPH_2_NAME)
@@ -265,12 +265,65 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
)
})
test(
'shows Blueprint tag only when editing a published blueprint',
{
tag: ['@vue-nodes']
},
async ({ comfyPage, subgraphBreadcrumb }) => {
const { panel } = subgraphBreadcrumb
const blueprintName = `bp-breadcrumb-tag-${Date.now()}`
await test.step('Tag is not shown on a normal workflow', async () => {
await expect(panel.blueprintTag).toHaveCount(0)
})
await test.step('Convert to Subgraph', async () => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.contextMenu.openForVueNode(ksampler.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
})
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await test.step('Unpublished subgraph does not show the blueprint tag', async () => {
await subgraphNode.centerOnNode()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(panel.blueprintTag).toHaveCount(0)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await test.step('Publish the subgraph as a blueprint', async () => {
await subgraphNode.click('title')
await comfyPage.subgraph.publishSubgraph(blueprintName)
})
await test.step('Open the blueprint from the node library', async () => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
await tab.getFolder('My Blueprints').click()
await tab.getFolder('User').click()
const blueprintNode = tab.getNode(blueprintName)
await expect(blueprintNode).toBeVisible()
await blueprintNode.getByRole('button', { name: 'Edit' }).click()
})
await test.step('Blueprint tag renders on the root breadcrumb', async () => {
await expect(panel.rootBlueprintTag).toBeVisible()
})
}
)
test('collapses when overflowing and expands when there is room', async ({
comfyPage,
subgraphBreadcrumb
}) => {
const { panel } = subgraphBreadcrumb
const rootItem = panel.rootItem()
const rootItem = panel.rootItem
const subgraph2Item = panel.subgraphItem(SUBGRAPH_2_ID)
const subgraph3Item = panel.subgraphItem(SUBGRAPH_3_ID)
const originalViewport = comfyPage.page.viewportSize()

View File

@@ -196,6 +196,48 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
})
.toStrictEqual(before)
})
test('Selecting a ratio preset auto-enables aspect ratio lock', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' }),
'lock button should start in unlocked state'
).toBeVisible()
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '4:3', exact: true })
.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' }),
'selecting a preset should auto-lock the ratio'
).toBeVisible()
})
test('Unlocking after a preset selection shows Custom in ratio dropdown', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '1:1', exact: true })
.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node.getByRole('combobox'),
'dropdown should revert to Custom after unlock'
).toContainText('Custom')
})
})
test.describe(
@@ -813,6 +855,339 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
await comfyPage.page.unroute('**/api/view**')
}
})
test('Selecting 16:9 ratio adjusts crop height proportionally', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 50,
width: 360,
height: 360
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const expectedHeight = Math.round(before.width / (16 / 9))
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '16:9', exact: true })
.click()
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v || v.width !== before.width) return false
return Math.abs(v.height - expectedHeight) <= 2
},
{ message: '16:9 ratio should adjust crop height proportionally' }
)
.toBe(true)
})
test('Selecting Custom from ratio dropdown unlocks aspect ratio', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 50,
width: 200,
height: 200
})
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '1:1', exact: true })
.click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'preset selection should lock ratio and show 4 handles'
).toHaveCount(4)
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: 'Custom', exact: true })
.click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'selecting Custom should unlock ratio and restore 8 handles'
).toHaveCount(8)
})
test('Unlock button releases locked ratio and restores all 8 resize handles', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'lock should reduce handles to 4'
).toHaveCount(4)
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' }),
'lock button aria-label should update after locking'
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'unlock should restore all 8 handles'
).toHaveCount(8)
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' }),
'lock button aria-label should revert after unlocking'
).toBeVisible()
})
test('Constrained resize from NW corner adjusts origin and dimensions proportionally', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 150,
y: 140,
width: 200,
height: 150
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const ratio = before.width / before.height
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-nw'),
-45,
-35
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.x < before.x &&
v.y < before.y &&
v.width > before.width &&
v.height > before.height &&
Math.abs(v.width / v.height - ratio) < 0.06
)
},
{
message:
'constrained NW resize should grow box and maintain aspect ratio'
}
)
.toBe(true)
})
test('Constrained resize clamps to image boundary while maintaining ratio', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await waitForImageNaturalSize(img)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
await setCropBounds(comfyPage, 2, {
x: nw - 100,
y: nh - 75,
width: 80,
height: 60
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const initialRatio = before.width / before.height
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
600,
400
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
const withinBounds = v.x + v.width <= nw && v.y + v.height <= nh
const ratio = v.width / v.height
const ratioPreserved = Math.abs(ratio - initialRatio) < 0.05
return withinBounds && ratioPreserved
},
{
message:
'constrained resize should stay within image boundaries and preserve aspect ratio'
}
)
.toBe(true)
})
test('Constrained NW corner resize clamps at top-left image boundary', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 30,
y: 25,
width: 160,
height: 120
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-nw'),
-400,
-300
)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.x ?? -1, {
message: 'constrained NW resize should clamp x to image boundary'
})
.toBeGreaterThanOrEqual(0)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.y ?? -1, {
message: 'constrained NW resize should clamp y to image boundary'
})
.toBeGreaterThanOrEqual(0)
})
test('Constrained resize enforces minimum crop size', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 100,
width: 60,
height: 60
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
-300,
-300
)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.width ?? 0, {
message: 'constrained resize should respect minimum width'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.height ?? 0, {
message: 'constrained resize should respect minimum height'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
})
test('Incrementing X in BoundingBox moves crop box right', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 80,
width: 200,
height: 150
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await node
.getByTestId('bounding-box-x')
.getByTestId('increment')
.click()
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.x, {
message: 'incrementing X should move crop right by 1'
})
.toBe(before.x + 1)
})
test('Incrementing Width in BoundingBox increases crop width', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 80,
width: 200,
height: 150
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await node
.getByTestId('bounding-box-width')
.getByTestId('increment')
.click()
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.width, {
message: 'incrementing Width should increase crop width by 1'
})
.toBe(before.width + 1)
})
test('BoundingBox numeric inputs reflect crop position after drag', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 60,
width: 200,
height: 150
})
const xInput = node.getByTestId('bounding-box-x').locator('input')
await expect
.poll(async () => Number(await xInput.inputValue()), {
message: 'X input should show initial crop x value'
})
.toBe(50)
await dragOnLocator(comfyPage, node.getByTestId('crop-overlay'), 40, 20)
await expect
.poll(async () => Number(await xInput.inputValue()), {
message: 'X input should update after crop drag'
})
.toBeGreaterThan(50)
})
}
)

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.9",
"version": "1.44.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

61
pnpm-lock.yaml generated
View File

@@ -952,6 +952,9 @@ importers:
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
zod:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
'@astrojs/check':
specifier: 'catalog:'
@@ -971,9 +974,15 @@ importers:
tailwindcss:
specifier: 'catalog:'
version: 4.2.0
tsx:
specifier: 'catalog:'
version: 4.19.4
typescript:
specifier: 'catalog:'
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/design-system:
dependencies:
@@ -14105,6 +14114,14 @@ snapshots:
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@@ -14139,7 +14156,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20320,6 +20337,48 @@ snapshots:
- tsx
- yaml
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
'@vitest/spy': 4.0.16
'@vitest/utils': 4.0.16
es-module-lexer: 1.7.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.0.3
'@vitest/ui': 4.0.16(vitest@4.0.16)
happy-dom: 20.0.11
jsdom: 27.4.0
transitivePeerDependencies:
- '@vitejs/devtools'
- esbuild
- jiti
- less
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
void-elements@3.1.0: {}
volar-service-css@0.0.70(@volar/language-service@2.4.28):

View File

@@ -3,19 +3,43 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
<ScrubableNumberInput
v-model="x"
:min="0"
:step="1"
:disabled
data-testid="bounding-box-x"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
<ScrubableNumberInput
v-model="y"
:min="0"
:step="1"
:disabled
data-testid="bounding-box-y"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
<ScrubableNumberInput
v-model="width"
:min="1"
:step="1"
:disabled
data-testid="bounding-box-width"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
<ScrubableNumberInput
v-model="height"
:min="1"
:step="1"
:disabled
data-testid="bounding-box-height"
/>
</div>
</template>

View File

@@ -26,7 +26,7 @@
<Tag
v-if="item.isBlueprint"
data-testid="subgraph-breadcrumb-blueprint-tag"
value="Blueprint"
:value="t('breadcrumbsMenu.blueprint')"
severity="primary"
/>
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>

View File

@@ -0,0 +1,253 @@
/* eslint-disable vue/no-reserved-component-names */
/* eslint-disable vue/no-unused-emit-declarations */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
curveWidget: {
linear: 'Linear',
monotone_cubic: 'Smooth',
step: 'Step'
}
}
}
})
const upstreamHolder = vi.hoisted(() => ({
ref: null as { value: unknown } | null
}))
vi.mock('@/composables/useUpstreamValue', async () => {
const { ref } = await import('vue')
return {
useUpstreamValue: () => {
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
return upstreamHolder.ref
},
singleValueExtractor: () => () => undefined
}
})
const outputsHolder = vi.hoisted(() => ({
nodeOutputs: {} as Record<string, unknown>
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => outputsHolder
}))
import WidgetCurve from './WidgetCurve.vue'
import type { CurveData } from './types'
const CurveEditorStub = defineComponent({
name: 'CurveEditor',
props: {
modelValue: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
interpolation: { type: String, default: '' },
histogram: { type: Object, default: null }
},
emits: ['update:modelValue'],
template: `
<div data-testid="curve-editor"
:data-disabled="String(disabled)"
:data-interpolation="interpolation"
:data-has-histogram="String(!!histogram)"
:data-points="JSON.stringify(modelValue)"
@click="$emit('update:modelValue', [[0,0],[0.5,1],[1,0]])"
/>
`
})
const SelectStub = defineComponent({
name: 'Select',
props: { modelValue: { type: String, default: '' } },
emits: ['update:modelValue'],
template: `
<div data-testid="interp-select" :data-value="modelValue">
<button
data-testid="select-linear"
@click="$emit('update:modelValue', 'linear')"
>linear</button>
<slot />
</div>
`
})
const Passthrough = defineComponent({
name: 'SelectPassthrough',
template: '<slot />'
})
function makeWidget(
overrides: Partial<SimplifiedWidget<CurveData>> = {}
): SimplifiedWidget<CurveData> {
return {
name: 'curve_w',
type: 'curve',
value: {
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
},
options: {},
...overrides
} as unknown as SimplifiedWidget<CurveData>
}
function setUpstream(value: CurveData | undefined) {
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
upstreamHolder.ref.value = value
}
function renderWidget(
widget: SimplifiedWidget<CurveData>,
initialModel: CurveData = {
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
}
) {
const value = ref<CurveData>(initialModel)
const Harness = defineComponent({
components: { WidgetCurve },
setup: () => ({ value, widget }),
template: '<WidgetCurve v-model="value" :widget="widget" />'
})
const utils = render(Harness, {
global: {
plugins: [i18n],
stubs: {
CurveEditor: CurveEditorStub,
Select: SelectStub,
SelectContent: Passthrough,
SelectTrigger: Passthrough,
SelectValue: Passthrough,
SelectItem: Passthrough
}
}
})
return { ...utils, value }
}
describe('WidgetCurve', () => {
beforeEach(() => {
upstreamHolder.ref = null
outputsHolder.nodeOutputs = {}
})
describe('Point forwarding', () => {
it('forwards model points to CurveEditor', () => {
renderWidget(makeWidget(), {
points: [
[0, 0],
[0.5, 0.2],
[1, 1]
],
interpolation: 'monotone_cubic'
})
const parsed = JSON.parse(
screen.getByTestId('curve-editor').dataset.points!
)
expect(parsed).toEqual([
[0, 0],
[0.5, 0.2],
[1, 1]
])
})
it('updates v-model when CurveEditor emits new points', async () => {
const { value } = renderWidget(makeWidget())
const user = userEvent.setup()
await user.click(screen.getByTestId('curve-editor'))
expect(value.value.points).toEqual([
[0, 0],
[0.5, 1],
[1, 0]
])
})
it('preserves interpolation when points change', async () => {
const { value } = renderWidget(makeWidget(), {
points: [
[0, 0],
[1, 1]
],
interpolation: 'linear'
})
const user = userEvent.setup()
await user.click(screen.getByTestId('curve-editor'))
expect(value.value.interpolation).toBe('linear')
})
})
describe('Interpolation select', () => {
it('shows the Select when not disabled', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('interp-select')).toBeInTheDocument()
})
it('hides the Select when disabled', () => {
renderWidget(makeWidget({ options: { disabled: true } }))
expect(screen.queryByTestId('interp-select')).toBeNull()
})
it('updates interpolation in v-model when Select emits a change', async () => {
const { value } = renderWidget(makeWidget())
const user = userEvent.setup()
await user.click(screen.getByTestId('select-linear'))
expect(value.value.interpolation).toBe('linear')
})
it('preserves points when interpolation changes', async () => {
const original: CurveData = {
points: [
[0, 0],
[0.3, 0.8],
[1, 1]
],
interpolation: 'monotone_cubic'
}
const { value } = renderWidget(makeWidget(), original)
const user = userEvent.setup()
await user.click(screen.getByTestId('select-linear'))
expect(value.value.points).toEqual(original.points)
})
})
describe('Disabled state + upstream', () => {
it('uses upstream curve when disabled and upstream is available', () => {
const upstream: CurveData = {
points: [
[0, 0],
[0.5, 0.5],
[1, 1]
],
interpolation: 'linear'
}
setUpstream(upstream)
renderWidget(
makeWidget({
options: { disabled: true },
linkedUpstream: { nodeId: 'n1' }
})
)
const parsed = JSON.parse(
screen.getByTestId('curve-editor').dataset.points!
)
expect(parsed).toEqual(upstream.points)
})
})
})

View File

@@ -0,0 +1,77 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
const mockExecute = vi.hoisted(() => vi.fn())
const mockSelectionState = vi.hoisted(() => ({
isSingleImageNode: { value: true }
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: mockExecute })
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => mockSelectionState
}))
const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: 'en',
messages: {
en: {
commands: {
Comfy_MaskEditor_OpenMaskEditor: { label: 'Open in Mask Editor' }
}
}
}
})
const renderButton = () =>
render(MaskEditorButton, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
describe('MaskEditorButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectionState.isSingleImageNode = ref(true)
})
it('should render with the localized aria-label when a single image node is selected', () => {
renderButton()
expect(
screen.getByRole('button', { name: 'Open in Mask Editor' })
).toBeInTheDocument()
})
it('should hide via v-show when no single image node is selected', () => {
mockSelectionState.isSingleImageNode = ref(false)
renderButton()
const btn = screen.getByLabelText('Open in Mask Editor', {
selector: 'button'
})
expect(btn.getAttribute('style') ?? '').toContain('display: none')
})
it('should execute the OpenMaskEditor command on click', async () => {
const user = userEvent.setup()
renderButton()
await user.click(
screen.getByRole('button', { name: 'Open in Mask Editor' })
)
expect(mockExecute).toHaveBeenCalledWith('Comfy.MaskEditor.OpenMaskEditor')
})
})

View File

@@ -0,0 +1,178 @@
import { render, screen } from '@testing-library/vue'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BrushCursor from '@/components/maskeditor/BrushCursor.vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
const initialMock = () =>
reactive({
brushVisible: true,
brushPreviewGradientVisible: false,
brushSettings: {
type: BrushShape.Arc,
size: 20,
opacity: 0.7,
hardness: 1,
stepSize: 5
},
zoomRatio: 1,
cursorPoint: { x: 100, y: 50 },
panOffset: { x: 0, y: 0 }
})
let mockStore: ReturnType<typeof initialMock>
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
const renderCursor = (containerRef?: HTMLElement) =>
render(BrushCursor, {
props: containerRef ? { containerRef } : {}
})
const getBrushEl = (): HTMLElement => screen.getByTestId('brush-cursor')
const getGradientEl = (): HTMLElement =>
screen.getByTestId('brush-cursor-gradient')
describe('BrushCursor', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
mockStore = initialMock()
})
describe('opacity', () => {
it('should be 1 when brushVisible is true', () => {
renderCursor()
expect(styleOf(getBrushEl())).toContain('opacity: 1')
})
it('should be 0 when brushVisible is false', () => {
mockStore.brushVisible = false
renderCursor()
expect(styleOf(getBrushEl())).toContain('opacity: 0')
})
})
describe('size and shape', () => {
it('should compute size as 2 * effectiveBrushSize * zoomRatio', () => {
// size=20, hardness=1 → effective=20; zoom=2 → diameter = 80
mockStore.brushSettings.size = 20
mockStore.brushSettings.hardness = 1
mockStore.zoomRatio = 2
renderCursor()
const style = styleOf(getBrushEl())
expect(style).toContain('width: 80px')
expect(style).toContain('height: 80px')
})
it('should grow effective size when hardness drops below 1', () => {
mockStore.brushSettings.size = 100
mockStore.brushSettings.hardness = 0
mockStore.zoomRatio = 1
renderCursor()
// effective = 100 * (1 + 1.0 * 0.5) = 150 → diameter = 300
expect(styleOf(getBrushEl())).toContain('width: 300px')
})
it('should use 50% borderRadius for Arc brush', () => {
mockStore.brushSettings.type = BrushShape.Arc
renderCursor()
expect(styleOf(getBrushEl())).toContain('border-radius: 50%')
})
it('should use 0% borderRadius for Rect brush', () => {
mockStore.brushSettings.type = BrushShape.Rect
renderCursor()
expect(styleOf(getBrushEl())).toContain('border-radius: 0%')
})
})
describe('position', () => {
it('should anchor to cursorPoint plus panOffset minus radius (no container)', () => {
mockStore.cursorPoint = { x: 200, y: 300 }
mockStore.panOffset = { x: 50, y: 25 }
mockStore.brushSettings.size = 20
mockStore.brushSettings.hardness = 1
mockStore.zoomRatio = 1
renderCursor()
// radius = effective(20,1) * 1 = 20
// left = 200 + 50 - 20 = 230
// top = 300 + 25 - 20 = 305
const style = styleOf(getBrushEl())
expect(style).toContain('left: 230px')
expect(style).toContain('top: 305px')
})
it('should subtract container offset when containerRef is provided', () => {
mockStore.cursorPoint = { x: 200, y: 300 }
mockStore.panOffset = { x: 0, y: 0 }
mockStore.brushSettings.size = 20
mockStore.brushSettings.hardness = 1
mockStore.zoomRatio = 1
const container = document.createElement('div')
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
left: 30,
top: 60,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({})
} as DOMRect)
renderCursor(container)
// left = 200 + 0 - 20 - 30 = 150; top = 300 + 0 - 20 - 60 = 220
const style = styleOf(getBrushEl())
expect(style).toContain('left: 150px')
expect(style).toContain('top: 220px')
})
})
describe('gradient preview', () => {
it('should be hidden by default', () => {
mockStore.brushPreviewGradientVisible = false
renderCursor()
expect(styleOf(getGradientEl())).toContain('display: none')
})
it('should be visible when brushPreviewGradientVisible is true', () => {
mockStore.brushPreviewGradientVisible = true
renderCursor()
expect(styleOf(getGradientEl())).toContain('display: block')
})
it('should use a flat fill at hardness=1', () => {
mockStore.brushPreviewGradientVisible = true
mockStore.brushSettings.hardness = 1
mockStore.brushSettings.size = 20
renderCursor()
// hard brush: getEffectiveHardness = (20*1)/20 = 1 → flat color
const style = styleOf(getGradientEl())
expect(style).toContain('rgba(255, 0, 0, 0.5)')
expect(style).not.toContain('radial-gradient')
})
// The radial-gradient (hardness < 1) branch uses a multi-line template
// literal as the background value; happy-dom's CSS parser drops the
// declaration entirely, so we can't assert on the rendered style. The
// underlying math (getEffectiveBrushSize / getEffectiveHardness) is
// covered by brushUtils.test.ts.
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
id="maskEditor_brush"
data-testid="brush-cursor"
:style="{
position: 'absolute',
opacity: brushOpacity,
@@ -15,6 +16,7 @@
>
<div
id="maskEditor_brushPreviewGradient"
data-testid="brush-cursor-gradient"
:style="{
display: gradientVisible ? 'block' : 'none',
background: gradientBackground

View File

@@ -0,0 +1,230 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- shape buttons are unlabeled divs and number inputs have no aria labels */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import BrushSettingsPanel from '@/components/maskeditor/BrushSettingsPanel.vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
const initialMock = () => ({
brushSettings: reactive({
type: BrushShape.Arc,
size: 10,
opacity: 0.7,
hardness: 1,
stepSize: 5
}),
rgbColor: '#FF0000',
colorInput: null as HTMLInputElement | null,
setBrushSize: vi.fn(),
setBrushOpacity: vi.fn(),
setBrushHardness: vi.fn(),
setBrushStepSize: vi.fn(),
resetBrushToDefault: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.5)">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
brushSettings: 'Brush Settings',
brushShape: 'Brush Shape',
colorSelector: 'Color Selector',
thickness: 'Thickness',
opacity: 'Opacity',
hardness: 'Hardness',
stepSize: 'Step Size',
resetToDefault: 'Reset to Default'
}
}
}
})
const renderPanel = () =>
render(BrushSettingsPanel, { global: { plugins: [i18n] } })
const setNumberInput = (input: HTMLInputElement, value: string): void => {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('BrushSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('brush shape buttons', () => {
it('should set brushSettings.type to Arc when arc button clicked', async () => {
mockStore.brushSettings.type = BrushShape.Rect
const { container } = renderPanel()
const user = userEvent.setup()
const arcEl = container.querySelector(
'.maskEditor_sidePanelBrushShapeCircle'
)
await user.click(arcEl as Element)
expect(mockStore.brushSettings.type).toBe(BrushShape.Arc)
})
it('should set brushSettings.type to Rect when rect button clicked', async () => {
const { container } = renderPanel()
const user = userEvent.setup()
const rectEl = container.querySelector(
'.maskEditor_sidePanelBrushShapeSquare'
)
await user.click(rectEl as Element)
expect(mockStore.brushSettings.type).toBe(BrushShape.Rect)
})
})
describe('reset button', () => {
it('should call resetBrushToDefault when clicked', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Reset to Default' }))
expect(mockStore.resetBrushToDefault).toHaveBeenCalledTimes(1)
})
})
describe('numeric inputs', () => {
it('should call setBrushSize when size number input changes', () => {
const { container } = renderPanel()
const sizeInput = container.querySelectorAll(
'input[type="number"]'
)[0] as HTMLInputElement
setNumberInput(sizeInput, '50')
expect(mockStore.setBrushSize).toHaveBeenCalledWith(50)
})
it('should call setBrushOpacity when opacity number input changes', () => {
const { container } = renderPanel()
const opacityInput = container.querySelectorAll(
'input[type="number"]'
)[1] as HTMLInputElement
setNumberInput(opacityInput, '0.4')
expect(mockStore.setBrushOpacity).toHaveBeenCalledWith(0.4)
})
it('should call setBrushHardness when hardness number input changes', () => {
const { container } = renderPanel()
const hardnessInput = container.querySelectorAll(
'input[type="number"]'
)[2] as HTMLInputElement
setNumberInput(hardnessInput, '0.6')
expect(mockStore.setBrushHardness).toHaveBeenCalledWith(0.6)
})
it('should call setBrushStepSize when step number input changes', () => {
const { container } = renderPanel()
const stepInput = container.querySelectorAll(
'input[type="number"]'
)[3] as HTMLInputElement
setNumberInput(stepInput, '20')
expect(mockStore.setBrushStepSize).toHaveBeenCalledWith(20)
})
})
describe('size slider (logarithmic)', () => {
it('should call setBrushSize with Math.round(Math.pow(250, value))', () => {
const { container } = renderPanel()
const sizeSlider = container.querySelectorAll(
'[data-slider="true"]'
)[0] as HTMLElement
sizeSlider.click()
// value = 0.5 → Math.round(Math.pow(250, 0.5)) = 16
expect(mockStore.setBrushSize).toHaveBeenCalledWith(16)
})
it('should map size 250 to slider value 1', () => {
mockStore.brushSettings.size = 250
const { container } = renderPanel()
const sizeSlider = container.querySelectorAll(
'[data-slider="true"]'
)[0] as HTMLElement
// Math.log(250) / Math.log(250) = 1
expect(sizeSlider.textContent).toContain('1')
})
it('should return cached raw slider value when size matches the mapping', async () => {
mockStore.setBrushSize.mockImplementation((size: number) => {
mockStore.brushSettings.size = size
})
const { container } = renderPanel()
const sizeSlider = container.querySelectorAll(
'[data-slider="true"]'
)[0] as HTMLElement
// Click sets rawSliderValue=0.5 → setBrushSize(16) → size=16
// → next getter run sees cached match → returns 0.5
sizeSlider.click()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sizeSlider.textContent).toContain('0.5')
})
})
describe('color input', () => {
it('should v-model rgbColor on the color input', () => {
const { container } = renderPanel()
const colorInput = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
colorInput.value = '#00ff00'
colorInput.dispatchEvent(new Event('input', { bubbles: true }))
expect(mockStore.rgbColor).toBe('#00ff00')
})
it('should expose color input ref to the store on mount', () => {
const { container } = renderPanel()
const colorInput = container.querySelector('input[type="color"]')
expect(mockStore.colorInput).toBe(colorInput)
})
it('should clear store.colorInput on unmount', () => {
const { unmount } = renderPanel()
expect(mockStore.colorInput).not.toBeNull()
unmount()
expect(mockStore.colorInput).toBeNull()
})
})
})

View File

@@ -0,0 +1,168 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ColorSelectSettingsPanel from '@/components/maskeditor/ColorSelectSettingsPanel.vue'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
const mockStore = vi.hoisted(() => ({
colorSelectTolerance: 20,
selectionOpacity: 100,
colorSelectLivePreview: false,
applyWholeImage: false,
colorComparisonMethod: 'simple' as ColorComparisonMethod,
maskBoundary: false,
maskTolerance: 0,
setColorSelectTolerance: vi.fn(),
setSelectionOpacity: vi.fn(),
setMaskTolerance: vi.fn()
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-control="slider" :aria-label="label" @click="$emit('update:modelValue', 99)">{{ modelValue }}</button>`
}
}))
vi.mock('@/components/maskeditor/controls/ToggleControl.vue', () => ({
default: {
name: 'ToggleControlStub',
props: ['label', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-control="toggle" :aria-label="label" @click="$emit('update:modelValue', !modelValue)">{{ modelValue }}</button>`
}
}))
vi.mock('@/components/maskeditor/controls/DropdownControl.vue', () => ({
default: {
name: 'DropdownControlStub',
props: ['label', 'options', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-control="dropdown" :aria-label="label" @click="$emit('update:modelValue', 'lab')">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
colorSelectSettings: 'Color Select Settings',
tolerance: 'Tolerance',
selectionOpacity: 'Selection Opacity',
livePreview: 'Live Preview',
applyToWholeImage: 'Apply to Whole Image',
method: 'Method',
stopAtMask: 'Stop at mask',
maskTolerance: 'Mask Tolerance'
}
}
}
})
const renderPanel = () =>
render(ColorSelectSettingsPanel, { global: { plugins: [i18n] } })
describe('ColorSelectSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.colorSelectTolerance = 20
mockStore.selectionOpacity = 100
mockStore.colorSelectLivePreview = false
mockStore.applyWholeImage = false
mockStore.colorComparisonMethod = ColorComparisonMethod.Simple
mockStore.maskBoundary = false
mockStore.maskTolerance = 0
})
it('should call setColorSelectTolerance when tolerance slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
expect(mockStore.setColorSelectTolerance).toHaveBeenCalledWith(99)
})
it('should call setSelectionOpacity when selection opacity slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Selection Opacity' }))
expect(mockStore.setSelectionOpacity).toHaveBeenCalledWith(99)
})
it('should toggle colorSelectLivePreview when live preview toggle emits', async () => {
const user = userEvent.setup()
mockStore.colorSelectLivePreview = false
renderPanel()
await user.click(screen.getByRole('button', { name: 'Live Preview' }))
expect(mockStore.colorSelectLivePreview).toBe(true)
})
it('should toggle applyWholeImage when whole-image toggle emits', async () => {
const user = userEvent.setup()
mockStore.applyWholeImage = false
renderPanel()
await user.click(
screen.getByRole('button', { name: 'Apply to Whole Image' })
)
expect(mockStore.applyWholeImage).toBe(true)
})
it('should set comparison method when dropdown emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Method' }))
expect(mockStore.colorComparisonMethod).toBe(ColorComparisonMethod.LAB)
})
it('should toggle maskBoundary when stop-at-mask toggle emits', async () => {
const user = userEvent.setup()
mockStore.maskBoundary = false
renderPanel()
await user.click(screen.getByRole('button', { name: 'Stop at mask' }))
expect(mockStore.maskBoundary).toBe(true)
})
it('should call setMaskTolerance when mask tolerance slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Mask Tolerance' }))
expect(mockStore.setMaskTolerance).toHaveBeenCalledWith(99)
})
it('should reflect store values on the controls', () => {
mockStore.colorSelectTolerance = 77
mockStore.colorComparisonMethod = ColorComparisonMethod.HSL
renderPanel()
expect(
screen.getByRole('button', { name: 'Tolerance' }).textContent
).toContain('77')
expect(
screen.getByRole('button', { name: 'Method' }).textContent
).toContain('hsl')
})
})

View File

@@ -0,0 +1,281 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- layer rows have unlabeled checkboxes and the blend-mode select has no role-friendly label */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import ImageLayerSettingsPanel from '@/components/maskeditor/ImageLayerSettingsPanel.vue'
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
type ToolManager = ReturnType<typeof useToolManager>
const initialMock = () =>
reactive({
maskOpacity: 0.8,
maskBlendMode: MaskBlendMode.Black,
activeLayer: 'mask' as 'mask' | 'rgb',
currentTool: Tools.MaskPen,
image: { src: 'https://example.com/base.png' } as { src: string } | null,
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
imgCanvas: null as HTMLCanvasElement | null,
setMaskOpacity: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
const mockUpdateMaskColor = vi.fn().mockResolvedValue(undefined)
const mockSetActiveLayer = vi.fn()
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
useCanvasManager: () => ({ updateMaskColor: mockUpdateMaskColor })
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button data-slider="true" @click="$emit('update:modelValue', 0.3)">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
layers: 'Layers',
maskOpacity: 'Mask Opacity',
maskBlendingOptions: 'Mask Blending Options',
black: 'Black',
white: 'White',
negative: 'Negative',
maskLayer: 'Mask Layer',
paintLayer: 'Paint Layer',
baseImageLayer: 'Base Image Layer',
activateLayer: 'Activate Layer',
baseLayerPreview: 'Base layer preview'
}
}
}
})
const renderPanel = (props?: Record<string, unknown>) =>
render(ImageLayerSettingsPanel, {
global: { plugins: [i18n] },
props
})
const makeCanvas = (): HTMLCanvasElement => document.createElement('canvas')
describe('ImageLayerSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('mask opacity slider', () => {
it('should call setMaskOpacity and update mask canvas opacity', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.maskCanvas = canvas
const { container } = renderPanel()
await user.click(
container.querySelector('[data-slider="true"]') as HTMLElement
)
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
expect(canvas.style.opacity).toBe('0.3')
})
it('should leave canvas alone when no maskCanvas is set', async () => {
const user = userEvent.setup()
const { container } = renderPanel()
await expect(
user.click(
container.querySelector('[data-slider="true"]') as HTMLElement
)
).resolves.not.toThrow()
expect(mockStore.setMaskOpacity).toHaveBeenCalledWith(0.3)
})
})
describe('blend mode select', () => {
it.each([
['black', MaskBlendMode.Black],
['white', MaskBlendMode.White],
['negative', MaskBlendMode.Negative],
['unknown-fallback', MaskBlendMode.Black]
] as const)('should map %s to MaskBlendMode.%s', async (raw, expected) => {
const { container } = renderPanel()
const select = container.querySelector('select') as HTMLSelectElement
Object.defineProperty(select, 'value', {
value: raw,
configurable: true
})
select.dispatchEvent(new Event('change', { bubbles: true }))
await new Promise((r) => setTimeout(r, 0))
expect(mockStore.maskBlendMode).toBe(expected)
expect(mockUpdateMaskColor).toHaveBeenCalled()
})
})
describe('layer visibility checkboxes', () => {
it('should toggle mask canvas opacity to maskOpacity when checked, 0 when unchecked', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.maskCanvas = canvas
mockStore.maskOpacity = 0.5
const { container } = renderPanel()
const checkbox = container.querySelectorAll(
'input[type="checkbox"]'
)[0] as HTMLInputElement
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0')
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0.5')
})
it('should toggle paint (rgb) canvas opacity between 0 and 1', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.rgbCanvas = canvas
const { container } = renderPanel()
const checkbox = container.querySelectorAll(
'input[type="checkbox"]'
)[1] as HTMLInputElement
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0')
await user.click(checkbox)
expect(canvas.style.opacity).toBe('1')
})
it('should toggle base image canvas opacity between 0 and 1', async () => {
const user = userEvent.setup()
const canvas = makeCanvas()
mockStore.imgCanvas = canvas
const { container } = renderPanel()
const checkbox = container.querySelectorAll(
'input[type="checkbox"]'
)[2] as HTMLInputElement
await user.click(checkbox)
expect(canvas.style.opacity).toBe('0')
})
it('should not throw when toggling visibility for missing canvases', async () => {
const user = userEvent.setup()
const { container } = renderPanel()
const checkboxes = container.querySelectorAll('input[type="checkbox"]')
for (const cb of checkboxes) {
await expect(user.click(cb as HTMLInputElement)).resolves.not.toThrow()
}
})
})
describe('activate layer buttons', () => {
it('should forward the layer to toolManager.setActiveLayer when clicked', async () => {
const user = userEvent.setup()
mockStore.activeLayer = 'rgb'
renderPanel({
toolManager: {
setActiveLayer: mockSetActiveLayer
} as unknown as ToolManager
})
const [maskBtn] = screen.getAllByRole('button', {
name: 'Activate Layer'
})
await user.click(maskBtn)
expect(mockSetActiveLayer).toHaveBeenCalledWith('mask')
})
it('should not throw when toolManager prop is omitted', async () => {
const user = userEvent.setup()
mockStore.activeLayer = 'rgb'
renderPanel()
const [maskBtn] = screen.getAllByRole('button', {
name: 'Activate Layer'
})
await expect(user.click(maskBtn)).resolves.not.toThrow()
})
it('should mark the active-layer button disabled', () => {
mockStore.activeLayer = 'mask'
renderPanel()
const [maskBtn] = screen.getAllByRole('button', {
name: 'Activate Layer'
})
expect(maskBtn.hasAttribute('disabled')).toBe(true)
})
})
describe('paint layer activate visibility', () => {
const styleOf = (el: Element): string => el.getAttribute('style') ?? ''
it('should hide paint activate button when current tool is not Eraser', () => {
mockStore.currentTool = Tools.MaskPen
const { container } = renderPanel()
const buttons = Array.from(container.querySelectorAll('button'))
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
expect(paintBtn).toBeDefined()
expect(styleOf(paintBtn as Element)).toContain('display: none')
})
it('should show paint activate button when current tool is Eraser', () => {
mockStore.currentTool = Tools.Eraser
const { container } = renderPanel()
const buttons = Array.from(container.querySelectorAll('button'))
const paintBtn = buttons.find((b) => styleOf(b).includes('display:'))
expect(paintBtn).toBeDefined()
expect(styleOf(paintBtn as Element)).toContain('display: block')
})
})
describe('base image preview', () => {
it('should render base image src from store', () => {
mockStore.image = { src: 'https://example.com/img.png' }
renderPanel()
const img = screen.getByAltText('Base layer preview')
expect((img as HTMLImageElement).src).toBe('https://example.com/img.png')
})
it('should render empty src when no image', () => {
mockStore.image = null
renderPanel()
const img = screen.getByAltText('Base layer preview')
expect(img.getAttribute('src')).toBe('')
})
})
})

View File

@@ -0,0 +1,354 @@
import { render, screen, waitFor } from '@testing-library/vue'
import { reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
const mockKeyboard = vi.hoisted(() => ({
addListeners: vi.fn(),
removeListeners: vi.fn()
}))
const mockPanZoom = vi.hoisted(() => ({
initializeCanvasPanZoom: vi.fn().mockResolvedValue(undefined),
invalidatePanZoom: vi.fn().mockResolvedValue(undefined)
}))
const mockBrushDrawing = vi.hoisted(() => ({
initGPUResources: vi.fn().mockResolvedValue(undefined),
initPreviewCanvas: vi.fn(),
saveBrushSettings: vi.fn()
}))
const mockToolManager = vi.hoisted(() => ({
brushDrawing: mockBrushDrawing
}))
const mockImageLoader = vi.hoisted(() => ({
loadImages: vi.fn().mockResolvedValue({ width: 100, height: 100 })
}))
const mockMaskEditorLoader = vi.hoisted(() => ({
loadFromNode: vi.fn().mockResolvedValue(undefined)
}))
const mockCanvasHistory = vi.hoisted(() => ({
saveInitialState: vi.fn(),
clearStates: vi.fn()
}))
const initialMockStore = () =>
reactive({
activeLayer: 'mask' as 'mask' | 'rgb',
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
imgCanvas: null as HTMLCanvasElement | null,
canvasContainer: null as HTMLElement | null,
canvasBackground: null as HTMLElement | null,
canvasHistory: mockCanvasHistory,
resetState: vi.fn()
})
let mockStore: ReturnType<typeof initialMockStore>
const mockDataStore = vi.hoisted(() => ({
reset: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
vi.mock('@/composables/maskeditor/useKeyboard', () => ({
useKeyboard: () => mockKeyboard
}))
vi.mock('@/composables/maskeditor/usePanAndZoom', () => ({
usePanAndZoom: () => mockPanZoom
}))
vi.mock('@/composables/maskeditor/useToolManager', () => ({
useToolManager: () => mockToolManager
}))
vi.mock('@/composables/maskeditor/useImageLoader', () => ({
useImageLoader: () => mockImageLoader
}))
vi.mock('@/composables/maskeditor/useMaskEditorLoader', () => ({
useMaskEditorLoader: () => mockMaskEditorLoader
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: () => mockDataStore
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
default: {
name: 'LoadingOverlayStub',
props: ['loading', 'size'],
template: `<div data-testid="loading-overlay" :data-loading="loading" />`
}
}))
vi.mock('@/components/maskeditor/ToolPanel.vue', () => ({
default: {
name: 'ToolPanelStub',
props: ['toolManager'],
template: '<div data-testid="tool-panel-stub" />'
}
}))
vi.mock('@/components/maskeditor/PointerZone.vue', () => ({
default: {
name: 'PointerZoneStub',
props: ['toolManager', 'panZoom'],
template: '<div data-testid="pointer-zone-stub" />'
}
}))
vi.mock('@/components/maskeditor/SidePanel.vue', () => ({
default: {
name: 'SidePanelStub',
props: ['toolManager'],
template: '<div data-testid="side-panel-stub" />'
}
}))
vi.mock('@/components/maskeditor/BrushCursor.vue', () => ({
default: {
name: 'BrushCursorStub',
props: ['containerRef'],
template: '<div data-testid="brush-cursor-stub" />'
}
}))
const observeSpy = vi.fn()
const disconnectSpy = vi.fn()
let lastResizeCallback: ResizeObserverCallback | null = null
class MockResizeObserver {
observe = observeSpy
disconnect = disconnectSpy
unobserve = vi.fn()
constructor(cb: ResizeObserverCallback) {
lastResizeCallback = cb
}
}
// `node` only flows into mocked `loader.loadFromNode`, so a typed sentinel
// with a stable identity is enough — we never read its fields.
const fakeNode = { id: 1, title: 'test-node' } as unknown as LGraphNode
const renderContent = () =>
render(MaskEditorContent, { props: { node: fakeNode } })
let originalResizeObserver: typeof ResizeObserver | undefined
describe('MaskEditorContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMockStore()
mockMaskEditorLoader.loadFromNode.mockResolvedValue(undefined)
mockImageLoader.loadImages.mockResolvedValue({ width: 100, height: 100 })
mockPanZoom.initializeCanvasPanZoom.mockResolvedValue(undefined)
mockBrushDrawing.initGPUResources.mockResolvedValue(undefined)
originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver
})
afterEach(() => {
globalThis.ResizeObserver =
originalResizeObserver as unknown as typeof ResizeObserver
})
describe('mount', () => {
it('should add keyboard listeners on mount', () => {
renderContent()
expect(mockKeyboard.addListeners).toHaveBeenCalledTimes(1)
})
it('should observe the container with a ResizeObserver', async () => {
renderContent()
await waitFor(() => expect(observeSpy).toHaveBeenCalledTimes(1))
})
it('should invalidate pan/zoom on resize', async () => {
renderContent()
await waitFor(() => expect(observeSpy).toHaveBeenCalled())
mockPanZoom.invalidatePanZoom.mockClear()
lastResizeCallback?.([], {} as ResizeObserver)
await new Promise((r) => setTimeout(r, 0))
expect(mockPanZoom.invalidatePanZoom).toHaveBeenCalledTimes(1)
})
it('should assign canvas refs to the store before init', async () => {
renderContent()
await waitFor(() => expect(mockStore.maskCanvas).not.toBeNull())
expect(mockStore.rgbCanvas).not.toBeNull()
expect(mockStore.imgCanvas).not.toBeNull()
expect(mockStore.canvasContainer).not.toBeNull()
expect(mockStore.canvasBackground).not.toBeNull()
})
})
describe('init flow', () => {
it('should run the init chain in the documented order', async () => {
renderContent()
await waitFor(() => {
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
})
const orderOf = (fn: { mock: { invocationCallOrder: number[] } }) =>
fn.mock.invocationCallOrder[0]
expect(orderOf(mockMaskEditorLoader.loadFromNode)).toBeLessThan(
orderOf(mockImageLoader.loadImages)
)
expect(orderOf(mockImageLoader.loadImages)).toBeLessThan(
orderOf(mockPanZoom.initializeCanvasPanZoom)
)
expect(orderOf(mockPanZoom.initializeCanvasPanZoom)).toBeLessThan(
orderOf(mockCanvasHistory.saveInitialState)
)
expect(orderOf(mockCanvasHistory.saveInitialState)).toBeLessThan(
orderOf(mockBrushDrawing.initGPUResources)
)
expect(orderOf(mockBrushDrawing.initGPUResources)).toBeLessThan(
orderOf(mockBrushDrawing.initPreviewCanvas)
)
expect(mockMaskEditorLoader.loadFromNode).toHaveBeenCalledWith(fakeNode)
})
it('should reveal the child UI components after init succeeds', async () => {
renderContent()
expect(await screen.findByTestId('tool-panel-stub')).toBeInTheDocument()
expect(await screen.findByTestId('pointer-zone-stub')).toBeInTheDocument()
expect(await screen.findByTestId('side-panel-stub')).toBeInTheDocument()
expect(await screen.findByTestId('brush-cursor-stub')).toBeInTheDocument()
})
it('should size the GPU preview canvas to match the mask canvas', async () => {
// Force the mask canvas to non-default dimensions during init so the
// assertion below proves the source actually copies width/height across
// (default 300x150 on both would make the test tautological).
mockBrushDrawing.initGPUResources.mockImplementationOnce(async () => {
if (mockStore.maskCanvas) {
mockStore.maskCanvas.width = 999
mockStore.maskCanvas.height = 777
}
})
renderContent()
await waitFor(() => {
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
})
const previewCanvas = mockBrushDrawing.initPreviewCanvas.mock
.calls[0][0] as HTMLCanvasElement
expect(previewCanvas.width).toBe(999)
expect(previewCanvas.height).toBe(777)
})
})
describe('init error', () => {
it('should close the dialog and log when loader.loadFromNode rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockMaskEditorLoader.loadFromNode.mockRejectedValueOnce(
new Error('load failed')
)
renderContent()
await waitFor(() => {
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorContent] Initialization failed:',
expect.any(Error)
)
errorSpy.mockRestore()
})
it('should close the dialog and log when initializeCanvasPanZoom rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPanZoom.initializeCanvasPanZoom.mockRejectedValueOnce(
new Error('panzoom failed')
)
renderContent()
await waitFor(() => {
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorContent] Initialization failed:',
expect.any(Error)
)
errorSpy.mockRestore()
})
})
describe('drag handling', () => {
it('should prevent default on dragstart with Ctrl held', () => {
renderContent()
const root = screen.getByTestId('mask-editor-root')
const event = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
// happy-dom doesn't propagate ctrlKey through the DragEvent constructor.
Object.defineProperty(event, 'ctrlKey', { value: true })
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('should not prevent default on plain dragstart without Ctrl', () => {
renderContent()
const root = screen.getByTestId('mask-editor-root')
const event = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
Object.defineProperty(event, 'ctrlKey', { value: false })
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
describe('unmount cleanup', () => {
it('should run the full cleanup chain on unmount', async () => {
const { unmount } = renderContent()
await waitFor(() =>
expect(mockBrushDrawing.initGPUResources).toHaveBeenCalled()
)
unmount()
expect(mockBrushDrawing.saveBrushSettings).toHaveBeenCalledTimes(1)
expect(mockKeyboard.removeListeners).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.clearStates).toHaveBeenCalledTimes(1)
expect(mockStore.resetState).toHaveBeenCalledTimes(1)
expect(mockDataStore.reset).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
ref="containerRef"
data-testid="mask-editor-root"
class="maskEditor-dialog-root flex size-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"

View File

@@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaintBucketSettingsPanel from '@/components/maskeditor/PaintBucketSettingsPanel.vue'
const mockStore = vi.hoisted(() => ({
paintBucketTolerance: 5,
fillOpacity: 100,
setPaintBucketTolerance: vi.fn(),
setFillOpacity: vi.fn()
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/controls/SliderControl.vue', () => ({
default: {
name: 'SliderControlStub',
props: ['label', 'min', 'max', 'step', 'modelValue'],
emits: ['update:modelValue'],
template: `<button :aria-label="label" @click="$emit('update:modelValue', 42)">{{ modelValue }}</button>`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
paintBucketSettings: 'Paint Bucket Settings',
tolerance: 'Tolerance',
fillOpacity: 'Fill Opacity'
}
}
}
})
const renderPanel = () =>
render(PaintBucketSettingsPanel, { global: { plugins: [i18n] } })
describe('PaintBucketSettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.paintBucketTolerance = 5
mockStore.fillOpacity = 100
})
it('should bind tolerance slider to store value', () => {
mockStore.paintBucketTolerance = 87
renderPanel()
expect(
screen.getByRole('button', { name: 'Tolerance' }).textContent
).toContain('87')
})
it('should bind fill opacity slider to store value', () => {
mockStore.fillOpacity = 33
renderPanel()
expect(
screen.getByRole('button', { name: 'Fill Opacity' }).textContent
).toContain('33')
})
it('should call setPaintBucketTolerance when tolerance slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Tolerance' }))
expect(mockStore.setPaintBucketTolerance).toHaveBeenCalledWith(42)
})
it('should call setFillOpacity when fill opacity slider emits', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: 'Fill Opacity' }))
expect(mockStore.setFillOpacity).toHaveBeenCalledWith(42)
})
})

View File

@@ -0,0 +1,184 @@
import { render, screen } from '@testing-library/vue'
import { reactive, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import PointerZone from '@/components/maskeditor/PointerZone.vue'
type ToolManager = ReturnType<typeof useToolManager>
type PanZoom = ReturnType<typeof usePanAndZoom>
const initialMock = () =>
reactive({
pointerZone: null as HTMLElement | null,
isPanning: false,
brushVisible: true
})
let mockStore: ReturnType<typeof initialMock>
const mockToolManager = vi.hoisted(() => ({
handlePointerDown: vi.fn().mockResolvedValue(undefined),
handlePointerMove: vi.fn().mockResolvedValue(undefined),
handlePointerUp: vi.fn().mockResolvedValue(undefined),
updateCursor: vi.fn()
}))
const mockPanZoom = vi.hoisted(() => ({
handleTouchStart: vi.fn(),
handleTouchMove: vi.fn().mockResolvedValue(undefined),
handleTouchEnd: vi.fn(),
zoom: vi.fn().mockResolvedValue(undefined),
updateCursorPosition: vi.fn()
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
const renderZone = () =>
render(PointerZone, {
props: {
toolManager: mockToolManager as unknown as ToolManager,
panZoom: mockPanZoom as unknown as PanZoom
}
})
const getZone = (): HTMLDivElement =>
screen.getByTestId('pointer-zone') as HTMLDivElement
describe('PointerZone', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('mount', () => {
it('should expose its root element to the store on mount', () => {
renderZone()
expect(mockStore.pointerZone).toBe(getZone())
})
})
describe('pointer event forwarding', () => {
it.each([
['pointerdown', 'handlePointerDown'],
['pointermove', 'handlePointerMove'],
['pointerup', 'handlePointerUp']
] as const)(
'should forward %s to toolManager.%s',
async (eventName, handlerName) => {
renderZone()
const zone = getZone()
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
await nextTick()
expect(mockToolManager[handlerName]).toHaveBeenCalledTimes(1)
}
)
it('should hide brush and clear cursor on pointerleave', () => {
renderZone()
const zone = getZone()
zone.style.cursor = 'crosshair'
mockStore.brushVisible = true
zone.dispatchEvent(new Event('pointerleave', { bubbles: true }))
expect(mockStore.brushVisible).toBe(false)
expect(zone.style.cursor).toBe('')
})
it('should call toolManager.updateCursor on pointerenter', () => {
renderZone()
const zone = getZone()
zone.dispatchEvent(new Event('pointerenter', { bubbles: true }))
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
})
})
describe('touch event forwarding', () => {
it.each([
['touchstart', 'handleTouchStart'],
['touchmove', 'handleTouchMove'],
['touchend', 'handleTouchEnd']
] as const)(
'should forward %s to panZoom.%s',
async (eventName, handlerName) => {
renderZone()
const zone = getZone()
zone.dispatchEvent(new Event(eventName, { bubbles: true }))
await nextTick()
expect(mockPanZoom[handlerName]).toHaveBeenCalledTimes(1)
}
)
})
describe('wheel handling', () => {
it('should call panZoom.zoom and update cursor position with the wheel coords', async () => {
renderZone()
const zone = getZone()
const event = new WheelEvent('wheel', { bubbles: true, deltaY: -1 })
// happy-dom doesn't propagate clientX/clientY through the WheelEvent
// constructor, so set them directly on the event instance.
Object.defineProperty(event, 'clientX', { value: 123 })
Object.defineProperty(event, 'clientY', { value: 45 })
zone.dispatchEvent(event)
// Flush awaited zoom() then the follow-up updateCursorPosition call
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockPanZoom.zoom).toHaveBeenCalledTimes(1)
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
x: 123,
y: 45
})
})
})
describe('isPanning watcher', () => {
it('should set cursor to "grabbing" when panning starts', async () => {
renderZone()
const zone = getZone()
mockStore.isPanning = true
await nextTick()
expect(zone.style.cursor).toBe('grabbing')
})
it('should call toolManager.updateCursor when panning ends', async () => {
renderZone()
mockStore.isPanning = true
await nextTick()
mockToolManager.updateCursor.mockClear()
mockStore.isPanning = false
await nextTick()
expect(mockToolManager.updateCursor).toHaveBeenCalledTimes(1)
})
})
describe('contextmenu', () => {
it('should prevent default on contextmenu', () => {
renderZone()
const zone = getZone()
const event = new Event('contextmenu', {
bubbles: true,
cancelable: true
})
zone.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
ref="pointerZoneRef"
data-testid="pointer-zone"
class="h-full w-[calc(100%-4rem-220px)]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"

View File

@@ -0,0 +1,70 @@
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SettingsPanelContainer from '@/components/maskeditor/SettingsPanelContainer.vue'
import { Tools } from '@/extensions/core/maskeditor/types'
const mockStore = vi.hoisted(() => ({
currentTool: 'pen' as Tools
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/components/maskeditor/BrushSettingsPanel.vue', () => ({
default: {
name: 'BrushSettingsPanelStub',
template: '<div>brush-panel</div>'
}
}))
vi.mock('@/components/maskeditor/ColorSelectSettingsPanel.vue', () => ({
default: {
name: 'ColorSelectSettingsPanelStub',
template: '<div>color-panel</div>'
}
}))
vi.mock('@/components/maskeditor/PaintBucketSettingsPanel.vue', () => ({
default: {
name: 'PaintBucketSettingsPanelStub',
template: '<div>bucket-panel</div>'
}
}))
describe('SettingsPanelContainer', () => {
beforeEach(() => {
mockStore.currentTool = Tools.MaskPen
})
it('should render PaintBucketSettingsPanel when current tool is MaskBucket', () => {
mockStore.currentTool = Tools.MaskBucket
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('bucket-panel')
})
it('should render ColorSelectSettingsPanel when current tool is MaskColorFill', () => {
mockStore.currentTool = Tools.MaskColorFill
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('color-panel')
})
it('should render BrushSettingsPanel for any other tool', () => {
mockStore.currentTool = Tools.MaskPen
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('brush-panel')
})
it('should render BrushSettingsPanel for Eraser', () => {
mockStore.currentTool = Tools.Eraser
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('brush-panel')
})
it('should render BrushSettingsPanel for PaintPen', () => {
mockStore.currentTool = Tools.PaintPen
const { container } = render(SettingsPanelContainer)
expect(container.textContent).toContain('brush-panel')
})
})

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import SidePanel from '@/components/maskeditor/SidePanel.vue'
type ToolManager = ReturnType<typeof useToolManager>
vi.mock('@/components/maskeditor/SettingsPanelContainer.vue', () => ({
default: {
name: 'SettingsPanelContainerStub',
template: '<div data-testid="settings-panel-stub">settings</div>'
}
}))
vi.mock('@/components/maskeditor/ImageLayerSettingsPanel.vue', () => ({
default: {
name: 'ImageLayerSettingsPanelStub',
props: ['toolManager'],
template:
'<div data-testid="image-layer-stub">image-layer:{{ toolManager?.tag ?? "none" }}</div>'
}
}))
describe('SidePanel', () => {
it('should render both child panels', () => {
render(SidePanel)
expect(screen.getByTestId('settings-panel-stub')).toBeInTheDocument()
expect(screen.getByTestId('image-layer-stub')).toBeInTheDocument()
})
it('should forward toolManager prop to ImageLayerSettingsPanel', () => {
const toolManager = { tag: 'my-tool-manager' } as unknown as ToolManager
render(SidePanel, { props: { toolManager } })
expect(screen.getByTestId('image-layer-stub').textContent).toContain(
'my-tool-manager'
)
})
it('should render with no toolManager passed through', () => {
render(SidePanel)
expect(screen.getByTestId('image-layer-stub').textContent).toContain('none')
})
})

View File

@@ -0,0 +1,155 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import ToolPanel from '@/components/maskeditor/ToolPanel.vue'
import { Tools, allTools } from '@/extensions/core/maskeditor/types'
type ToolManager = ReturnType<typeof useToolManager>
vi.mock('@/extensions/core/maskeditor/constants', () => ({
iconsHtml: {
pen: '<svg data-testid="icon-pen" />',
rgbPaint: '<svg data-testid="icon-rgbPaint" />',
eraser: '<svg data-testid="icon-eraser" />',
paintBucket: '<svg data-testid="icon-paintBucket" />',
colorSelect: '<svg data-testid="icon-colorSelect" />'
}
}))
const initialMock = () =>
reactive({
currentTool: Tools.MaskPen as Tools,
displayZoomRatio: 1,
image: null as { width: number; height: number } | null,
resetZoom: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
maskEditor: {
clickToResetZoom: 'Click to reset zoom'
}
}
}
})
const mockToolManager = vi.hoisted(() => ({
switchTool: vi.fn()
}))
const renderPanel = () =>
render(ToolPanel, {
global: { plugins: [i18n] },
props: { toolManager: mockToolManager as unknown as ToolManager }
})
const getToolButton = (tool: Tools): HTMLElement => {
const btns = screen.getAllByTestId('tool-button')
const match = btns.find((b) => b.dataset.tool === tool)
if (!match) throw new Error(`tool button for "${tool}" not found`)
return match
}
describe('ToolPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('tool list rendering', () => {
it('should render one button per tool in allTools', () => {
renderPanel()
expect(screen.getAllByTestId('tool-button')).toHaveLength(allTools.length)
})
it('should render the icon HTML for each tool', () => {
renderPanel()
for (const tool of allTools) {
expect(screen.getByTestId(`icon-${tool}`)).toBeInTheDocument()
}
})
})
describe('current tool highlight', () => {
it.each([Tools.MaskPen, Tools.Eraser, Tools.PaintPen] as const)(
'should mark the %s button as selected when it is the current tool',
(tool) => {
mockStore.currentTool = tool
renderPanel()
expect(getToolButton(tool).className).toContain(
'maskEditor_toolPanelContainerSelected'
)
}
)
it('should not mark non-current tools as selected', () => {
mockStore.currentTool = Tools.MaskPen
renderPanel()
for (const tool of allTools) {
if (tool === Tools.MaskPen) continue
expect(getToolButton(tool).className).not.toContain(
'maskEditor_toolPanelContainerSelected'
)
}
})
})
describe('tool selection', () => {
it('should call toolManager.switchTool with the clicked tool', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(getToolButton(Tools.Eraser))
expect(mockToolManager.switchTool).toHaveBeenCalledWith(Tools.Eraser)
})
})
describe('zoom indicator', () => {
it('should render rounded zoom percentage from displayZoomRatio', () => {
mockStore.displayZoomRatio = 1.236
renderPanel()
expect(screen.getByTestId('zoom-percentage').textContent).toBe('124%')
})
it('should render image dimensions when an image is loaded', () => {
mockStore.image = { width: 800, height: 600 }
renderPanel()
expect(screen.getByTestId('zoom-dimensions').textContent).toBe('800x600')
})
it('should render a single-space placeholder when no image', () => {
mockStore.image = null
renderPanel()
expect(screen.getByTestId('zoom-dimensions').textContent).toBe(' ')
})
it('should call resetZoom when the zoom indicator is clicked', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByTestId('zoom-percentage'))
expect(mockStore.resetZoom).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -4,6 +4,8 @@
<div
v-for="tool in allTools"
:key="tool"
data-testid="tool-button"
:data-tool="tool"
:class="[
'maskEditor_toolPanelContainer hover:bg-secondary-background-hover',
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
@@ -23,8 +25,12 @@
:title="t('maskEditor.clickToResetZoom')"
@click="onResetZoom"
>
<span class="text-sm text-text-secondary">{{ zoomText }}</span>
<span class="text-xs text-text-secondary">{{ dimensionsText }}</span>
<span data-testid="zoom-percentage" class="text-sm text-text-secondary">{{
zoomText
}}</span>
<span data-testid="zoom-dimensions" class="text-xs text-text-secondary">{{
dimensionsText
}}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import DropdownControl from './DropdownControl.vue'
const renderComponent = (
props: {
label?: string
options?: string[] | { label: string; value: string | number }[]
modelValue?: string | number
} = {},
onUpdate?: (value: string | number) => void
) => {
const user = userEvent.setup()
const utils = render(DropdownControl, {
props: {
label: 'Mode',
options: ['One', 'Two', 'Three'],
modelValue: 'One',
...props,
'onUpdate:modelValue': onUpdate
}
})
return { user, ...utils }
}
describe('DropdownControl', () => {
it('should render the label', () => {
renderComponent({ label: 'Brush Mode' })
expect(screen.getByText('Brush Mode')).toBeInTheDocument()
})
it('should expand string options to {label,value} pairs', () => {
renderComponent({ options: ['Alpha', 'Beta'], modelValue: 'Alpha' })
const select = screen.getByRole('combobox') as HTMLSelectElement
const values = Array.from(select.options).map((o) => o.value)
const labels = Array.from(select.options).map((o) => o.textContent?.trim())
expect(values).toEqual(['Alpha', 'Beta'])
expect(labels).toEqual(['Alpha', 'Beta'])
})
it('should preserve {label,value} options as-is', () => {
renderComponent({
options: [
{ label: 'High', value: 1 },
{ label: 'Low', value: 2 }
],
modelValue: 1
})
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['1', '2'])
expect(
Array.from(select.options).map((o) => o.textContent?.trim())
).toEqual(['High', 'Low'])
})
it('should reflect modelValue as the selected option', () => {
renderComponent({ options: ['One', 'Two'], modelValue: 'Two' })
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
'Two'
)
})
it('should emit update:modelValue with the chosen string value', async () => {
const onUpdate = vi.fn()
const { user } = renderComponent(
{ options: ['One', 'Two', 'Three'], modelValue: 'One' },
onUpdate
)
await user.selectOptions(screen.getByRole('combobox'), 'Three')
expect(onUpdate).toHaveBeenCalledWith('Three')
})
})

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import SliderControl from './SliderControl.vue'
const renderComponent = (
props: {
label?: string
min?: number
max?: number
step?: number
modelValue?: number
} = {},
onUpdate?: (value: number) => void
) => {
return render(SliderControl, {
props: {
label: 'Brush Size',
min: 1,
max: 100,
modelValue: 10,
...props,
'onUpdate:modelValue': onUpdate
}
})
}
const setSliderValue = (input: HTMLInputElement, value: string): void => {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('SliderControl', () => {
it('should render the label', () => {
renderComponent({ label: 'Hardness' })
expect(screen.getByText('Hardness')).toBeInTheDocument()
})
it('should expose min, max, step and modelValue on the input', () => {
renderComponent({ min: 0, max: 50, step: 5, modelValue: 25 })
const input = screen.getByRole('slider') as HTMLInputElement
expect(input.min).toBe('0')
expect(input.max).toBe('50')
expect(input.step).toBe('5')
expect(input.value).toBe('25')
})
it('should default step to 1 when not provided', () => {
renderComponent({ min: 0, max: 10, modelValue: 5 })
expect((screen.getByRole('slider') as HTMLInputElement).step).toBe('1')
})
it('should emit update:modelValue with a number when input changes', () => {
const onUpdate = vi.fn()
renderComponent({ min: 1, max: 100, modelValue: 10 }, onUpdate)
setSliderValue(screen.getByRole('slider') as HTMLInputElement, '42')
expect(onUpdate).toHaveBeenLastCalledWith(42)
expect(typeof onUpdate.mock.calls.at(-1)![0]).toBe('number')
})
})

View File

@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ToggleControl from './ToggleControl.vue'
const renderComponent = (
props: { label?: string; modelValue?: boolean } = {},
onUpdate?: (value: boolean) => void
) => {
const user = userEvent.setup()
const utils = render(ToggleControl, {
props: {
label: 'Smoothing',
modelValue: false,
...props,
'onUpdate:modelValue': onUpdate
}
})
return { user, ...utils }
}
describe('ToggleControl', () => {
it('should render the label', () => {
renderComponent({ label: 'Pressure Sensitivity' })
expect(screen.getByText('Pressure Sensitivity')).toBeInTheDocument()
})
it('should reflect modelValue=false as unchecked', () => {
renderComponent({ modelValue: false })
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
false
)
})
it('should reflect modelValue=true as checked', () => {
renderComponent({ modelValue: true })
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(
true
)
})
it('should emit update:modelValue=true when toggled on', async () => {
const onUpdate = vi.fn()
const { user } = renderComponent({ modelValue: false }, onUpdate)
await user.click(screen.getByRole('checkbox'))
expect(onUpdate).toHaveBeenCalledWith(true)
})
it('should emit update:modelValue=false when toggled off', async () => {
const onUpdate = vi.fn()
const { user } = renderComponent({ modelValue: true }, onUpdate)
await user.click(screen.getByRole('checkbox'))
expect(onUpdate).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,262 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
const mockCanvasHistory = vi.hoisted(() => ({
undo: vi.fn(),
redo: vi.fn()
}))
const initialMock = () =>
reactive({
canvasHistory: mockCanvasHistory,
brushVisible: true,
triggerClear: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
const mockCanvasTools = vi.hoisted(() => ({
invertMask: vi.fn(),
clearMask: vi.fn()
}))
const mockCanvasTransform = vi.hoisted(() => ({
rotateCounterclockwise: vi.fn().mockResolvedValue(undefined),
rotateClockwise: vi.fn().mockResolvedValue(undefined),
mirrorHorizontal: vi.fn().mockResolvedValue(undefined),
mirrorVertical: vi.fn().mockResolvedValue(undefined)
}))
const mockSaver = vi.hoisted(() => ({
save: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
useCanvasTools: () => mockCanvasTools
}))
vi.mock('@/composables/maskeditor/useCanvasTransform', () => ({
useCanvasTransform: () => mockCanvasTransform
}))
vi.mock('@/composables/maskeditor/useMaskEditorSaver', () => ({
useMaskEditorSaver: () => mockSaver
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
name: 'ButtonStub',
props: ['variant', 'disabled'],
template:
'<button :data-variant="variant" :disabled="disabled"><slot /></button>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
save: 'Save',
saving: 'Saving',
cancel: 'Cancel'
},
maskEditor: {
title: 'Mask Editor',
invert: 'Invert',
clear: 'Clear',
undo: 'Undo',
redo: 'Redo',
rotateLeft: 'Rotate Left',
rotateRight: 'Rotate Right',
mirrorHorizontal: 'Mirror Horizontal',
mirrorVertical: 'Mirror Vertical'
}
}
}
})
const renderHeader = () => render(TopBarHeader, { global: { plugins: [i18n] } })
describe('TopBarHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('title', () => {
it('should render the localized title', () => {
renderHeader()
expect(screen.getByText('Mask Editor')).toBeInTheDocument()
})
})
describe('history buttons', () => {
it('should call canvasHistory.undo when undo button is clicked', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Undo' }))
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
})
it('should call canvasHistory.redo when redo button is clicked', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Redo' }))
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
})
})
describe('canvas transform buttons', () => {
it.each([
['Rotate Left', 'rotateCounterclockwise'],
['Rotate Right', 'rotateClockwise'],
['Mirror Horizontal', 'mirrorHorizontal'],
['Mirror Vertical', 'mirrorVertical']
] as const)(
'should call canvasTransform.%s when %s button is clicked',
async (label, method) => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: label }))
expect(mockCanvasTransform[method]).toHaveBeenCalledTimes(1)
}
)
it.each([
['Rotate Left', 'rotateCounterclockwise', 'Rotate left failed:'],
['Rotate Right', 'rotateClockwise', 'Rotate right failed:'],
['Mirror Horizontal', 'mirrorHorizontal', 'Mirror horizontal failed:'],
['Mirror Vertical', 'mirrorVertical', 'Mirror vertical failed:']
] as const)(
'should swallow and log errors from %s',
async (label, method, expectedMsg) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockCanvasTransform[method].mockRejectedValueOnce(new Error('boom'))
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: label }))
expect(errorSpy).toHaveBeenCalledWith(
`[TopBarHeader] ${expectedMsg}`,
expect.any(Error)
)
errorSpy.mockRestore()
}
)
})
describe('mask edit buttons', () => {
it('should call canvasTools.invertMask on Invert click', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Invert' }))
expect(mockCanvasTools.invertMask).toHaveBeenCalledTimes(1)
})
it('should call clearMask and store.triggerClear on Clear click', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Clear' }))
expect(mockCanvasTools.clearMask).toHaveBeenCalledTimes(1)
expect(mockStore.triggerClear).toHaveBeenCalledTimes(1)
})
})
describe('save', () => {
it('should hide brush, save, and close the dialog on success', async () => {
const user = userEvent.setup()
renderHeader()
mockStore.brushVisible = true
await user.click(screen.getByRole('button', { name: /save/i }))
expect(mockStore.brushVisible).toBe(false)
expect(mockSaver.save).toHaveBeenCalledTimes(1)
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
it('should switch the button text to "Saving" and disable the button while saving', async () => {
let resolve!: () => void
mockSaver.save.mockReturnValueOnce(
new Promise<void>((r) => {
resolve = r
})
)
const user = userEvent.setup()
renderHeader()
const clickPromise = user.click(
screen.getByRole('button', { name: /save/i })
)
const savingBtn = await screen.findByRole('button', { name: 'Saving' })
expect(savingBtn).toBeDisabled()
resolve()
await clickPromise
})
it('should restore brush + button state and log on save failure', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSaver.save.mockRejectedValueOnce(new Error('save failed'))
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: /save/i }))
expect(mockStore.brushVisible).toBe(true)
expect(errorSpy).toHaveBeenCalledWith(
'[TopBarHeader] Save failed:',
expect.any(Error)
)
expect(mockDialogStore.closeDialog).not.toHaveBeenCalled()
// After failure, the Save button reads "Save" again (not "Saving")
expect(
screen.getByRole('button', { name: /save/i }).textContent?.trim()
).toBe('Save')
errorSpy.mockRestore()
})
})
describe('cancel', () => {
it('should close the dialog with the global-mask-editor key', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-mask-editor'
})
})
})
})

View File

@@ -0,0 +1,380 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCoordinateTransform } from '@/composables/maskeditor/useCoordinateTransform'
type MockStore = {
pointerZone: HTMLElement | null
canvasContainer: HTMLElement | null
maskCanvas: HTMLCanvasElement | null
}
const mockStore: MockStore = {
pointerZone: null,
canvasContainer: null,
maskCanvas: null
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: <T extends (...args: unknown[]) => unknown>(fn: T) =>
fn
}))
const createElementWithRect = (rect: Partial<DOMRect>): HTMLElement => {
const el = document.createElement('div')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
} as DOMRect)
return el
}
const createCanvasWithRect = (
rect: Partial<DOMRect>,
width: number,
height: number
): HTMLCanvasElement => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
} as DOMRect)
return canvas
}
describe('useCoordinateTransform', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.pointerZone = null
mockStore.canvasContainer = null
mockStore.maskCanvas = null
})
describe('screenToCanvas', () => {
it('should return canvas coordinates when display size matches canvas size', () => {
mockStore.pointerZone = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 100, top: 50, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 50, y: 30 })).toEqual({
x: 50,
y: 30
})
})
it('should apply scale when canvas is rendered smaller than its bitmap', () => {
mockStore.pointerZone = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.canvasContainer = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 0, top: 0, width: 100, height: 100 },
400,
400
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 25, y: 50 })).toEqual({
x: 100,
y: 200
})
})
it('should account for pointerZone offset relative to canvasContainer', () => {
mockStore.pointerZone = createElementWithRect({
left: 200,
top: 100,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 150,
top: 80,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 150, top: 80, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 0, y: 0 })).toEqual({
x: 50,
y: 20
})
})
it('should support non-uniform scale factors', () => {
mockStore.pointerZone = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 50
})
mockStore.canvasContainer = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 50
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 0, top: 0, width: 100, height: 50 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 10 })).toEqual({
x: 20,
y: 40
})
})
it('should return zero point and warn when pointerZone is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.canvasContainer = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalledWith(
'screenToCanvas called before elements are available'
)
warnSpy.mockRestore()
})
it('should return zero point when canvasContainer is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('should return zero point when maskCanvas is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.canvasContainer = createElementWithRect({})
const transform = useCoordinateTransform()
expect(transform.screenToCanvas({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
})
describe('canvasToScreen', () => {
it('should return pointerZone-relative coordinates when display matches bitmap', () => {
mockStore.pointerZone = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 100,
top: 50,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 100, top: 50, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 50, y: 30 })).toEqual({
x: 50,
y: 30
})
})
it('should apply inverse scale when canvas bitmap is larger than display', () => {
mockStore.pointerZone = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.canvasContainer = createElementWithRect({
left: 0,
top: 0,
width: 100,
height: 100
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 0, top: 0, width: 100, height: 100 },
400,
400
)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 100, y: 200 })).toEqual({
x: 25,
y: 50
})
})
it('should account for pointerZone offset relative to canvasContainer', () => {
mockStore.pointerZone = createElementWithRect({
left: 200,
top: 100,
width: 200,
height: 200
})
mockStore.canvasContainer = createElementWithRect({
left: 150,
top: 80,
width: 200,
height: 200
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 150, top: 80, width: 200, height: 200 },
200,
200
)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 50, y: 20 })).toEqual({ x: 0, y: 0 })
})
it('should round-trip with screenToCanvas', () => {
mockStore.pointerZone = createElementWithRect({
left: 50,
top: 25,
width: 300,
height: 300
})
mockStore.canvasContainer = createElementWithRect({
left: 70,
top: 40,
width: 300,
height: 300
})
mockStore.maskCanvas = createCanvasWithRect(
{ left: 70, top: 40, width: 300, height: 300 },
600,
600
)
const transform = useCoordinateTransform()
const original = { x: 123, y: 87 }
const canvasPoint = transform.screenToCanvas(original)
const back = transform.canvasToScreen(canvasPoint)
expect(back.x).toBeCloseTo(original.x)
expect(back.y).toBeCloseTo(original.y)
})
it('should return zero point and warn when pointerZone is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.canvasContainer = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalledWith(
'canvasToScreen called before elements are available'
)
warnSpy.mockRestore()
})
it('should return zero point when canvasContainer is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.maskCanvas = createCanvasWithRect({}, 100, 100)
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('should return zero point when maskCanvas is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockStore.pointerZone = createElementWithRect({})
mockStore.canvasContainer = createElementWithRect({})
const transform = useCoordinateTransform()
expect(transform.canvasToScreen({ x: 10, y: 20 })).toEqual({ x: 0, y: 0 })
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
})
})

View File

@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
type MockCanvasHistory = {
undo: ReturnType<typeof vi.fn>
redo: ReturnType<typeof vi.fn>
}
type MockStore = {
canvasHistory: MockCanvasHistory
}
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
const mockCanvasHistory: MockCanvasHistory = {
undo: vi.fn(),
redo: vi.fn()
}
const mockStore: MockStore = {
canvasHistory: mockCanvasHistory
}
return { mockStore, mockCanvasHistory }
})
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
const dispatchKeyDown = (
init: KeyboardEventInit & { key: string }
): KeyboardEvent => {
const event = new KeyboardEvent('keydown', { cancelable: true, ...init })
document.dispatchEvent(event)
return event
}
const dispatchKeyUp = (key: string): void => {
document.dispatchEvent(new KeyboardEvent('keyup', { key }))
}
describe('useKeyboard', () => {
let keyboard: ReturnType<typeof useKeyboard>
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
keyboard = useKeyboard()
keyboard.addListeners()
})
afterEach(() => {
keyboard.removeListeners()
})
describe('isKeyDown', () => {
it('should return false for keys that have not been pressed', () => {
expect(keyboard.isKeyDown('a')).toBe(false)
})
it('should return true after a key is pressed', () => {
dispatchKeyDown({ key: 'a' })
expect(keyboard.isKeyDown('a')).toBe(true)
})
it('should return false after a pressed key is released', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyUp('a')
expect(keyboard.isKeyDown('a')).toBe(false)
})
it('should track multiple keys independently', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'b' })
expect(keyboard.isKeyDown('a')).toBe(true)
expect(keyboard.isKeyDown('b')).toBe(true)
dispatchKeyUp('a')
expect(keyboard.isKeyDown('a')).toBe(false)
expect(keyboard.isKeyDown('b')).toBe(true)
})
})
describe('handleKeyDown', () => {
it('should not duplicate the same key on repeated keydown events', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'a' })
dispatchKeyUp('a')
expect(keyboard.isKeyDown('a')).toBe(false)
})
it('should prevent default and blur the active element on space', () => {
const input = document.createElement('input')
document.body.appendChild(input)
input.focus()
const blurSpy = vi.spyOn(input, 'blur')
const event = dispatchKeyDown({ key: ' ' })
expect(event.defaultPrevented).toBe(true)
expect(blurSpy).toHaveBeenCalledTimes(1)
expect(keyboard.isKeyDown(' ')).toBe(true)
})
it('should not throw when activeElement is null', () => {
Object.defineProperty(document, 'activeElement', {
value: null,
configurable: true
})
try {
expect(() => dispatchKeyDown({ key: ' ' })).not.toThrow()
} finally {
Reflect.deleteProperty(document, 'activeElement')
}
})
it('should call undo on Ctrl+Z without shift', () => {
dispatchKeyDown({ key: 'z', ctrlKey: true })
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
})
it('should call undo on Meta+Z without shift', () => {
dispatchKeyDown({ key: 'z', metaKey: true })
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
})
it('should call redo on Ctrl+Shift+Z', () => {
dispatchKeyDown({ key: 'Z', ctrlKey: true, shiftKey: true })
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
it('should call redo on Ctrl+Y', () => {
dispatchKeyDown({ key: 'y', ctrlKey: true })
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
it('should not trigger undo or redo when alt is held', () => {
dispatchKeyDown({ key: 'z', ctrlKey: true, altKey: true })
dispatchKeyDown({ key: 'y', ctrlKey: true, altKey: true })
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
})
it('should not trigger undo or redo without ctrl or meta', () => {
dispatchKeyDown({ key: 'z' })
dispatchKeyDown({ key: 'y' })
dispatchKeyDown({ key: 'Z', shiftKey: true })
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
})
it('should ignore Ctrl+Shift+Y', () => {
dispatchKeyDown({ key: 'Y', ctrlKey: true, shiftKey: true })
expect(mockCanvasHistory.redo).not.toHaveBeenCalled()
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
})
describe('addListeners', () => {
it('should clear all tracked keys when the window loses focus', () => {
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'b' })
window.dispatchEvent(new Event('blur'))
expect(keyboard.isKeyDown('a')).toBe(false)
expect(keyboard.isKeyDown('b')).toBe(false)
})
})
describe('removeListeners', () => {
it('should stop responding to keyboard events after removal', () => {
keyboard.removeListeners()
dispatchKeyDown({ key: 'a' })
dispatchKeyDown({ key: 'z', ctrlKey: true })
expect(keyboard.isKeyDown('a')).toBe(false)
expect(mockCanvasHistory.undo).not.toHaveBeenCalled()
})
it('should stop clearing keys on window blur after removal', () => {
dispatchKeyDown({ key: 'a' })
keyboard.removeListeners()
window.dispatchEvent(new Event('blur'))
expect(keyboard.isKeyDown('a')).toBe(true)
})
})
})

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const mockDialogStore = vi.hoisted(() => ({
showDialog: vi.fn()
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/components/maskeditor/dialog/TopBarHeader.vue', () => ({
default: { name: 'TopBarHeaderStub' }
}))
vi.mock('@/components/maskeditor/MaskEditorContent.vue', () => ({
default: { name: 'MaskEditorContentStub' }
}))
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
type NodeShape = {
imgs?: unknown[]
previewMediaType?: string
}
const nodeWithImage = (overrides: NodeShape = {}): LGraphNode =>
({
imgs: [new Image()],
previewMediaType: undefined,
...overrides
}) as unknown as LGraphNode
describe('useMaskEditor', () => {
let errorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('openMaskEditor', () => {
it('should open the dialog with the node forwarded as a prop', () => {
const node = nodeWithImage()
useMaskEditor().openMaskEditor(node)
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'global-mask-editor',
props: { node }
})
)
})
it('should pass header and content components to the dialog', () => {
useMaskEditor().openMaskEditor(nodeWithImage())
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
headerComponent: expect.anything(),
component: expect.anything()
})
)
})
it('should configure modal dialog with maximizable and closable flags', () => {
useMaskEditor().openMaskEditor(nodeWithImage())
expect(mockDialogStore.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
dialogComponentProps: expect.objectContaining({
modal: true,
maximizable: true,
closable: true
})
})
)
})
it('should accept a node whose previewMediaType is "image" without imgs', () => {
const node = nodeWithImage({
imgs: undefined,
previewMediaType: 'image'
})
useMaskEditor().openMaskEditor(node)
expect(mockDialogStore.showDialog).toHaveBeenCalledTimes(1)
})
it('should log and bail when node is null', () => {
useMaskEditor().openMaskEditor(null as unknown as LGraphNode)
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] No node provided')
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
})
it('should log and bail when node has neither imgs nor image preview', () => {
const node = nodeWithImage({ imgs: [], previewMediaType: undefined })
useMaskEditor().openMaskEditor(node)
expect(errorSpy).toHaveBeenCalledWith('[MaskEditor] Node has no images')
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
})
it('should bail when node has empty imgs and a non-image preview type', () => {
const node = nodeWithImage({ imgs: [], previewMediaType: 'video' })
useMaskEditor().openMaskEditor(node)
expect(mockDialogStore.showDialog).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,517 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, reactive } from 'vue'
import type { EffectScope } from 'vue'
import { useBrushDrawing } from '@/composables/maskeditor/useBrushDrawing'
import { useToolManager } from '@/composables/maskeditor/useToolManager'
import { Tools } from '@/extensions/core/maskeditor/types'
type MockStore = {
currentTool: Tools
activeLayer: 'mask' | 'rgb'
pointerZone: HTMLElement | null
brushVisible: boolean
brushPreviewGradientVisible: boolean
isAdjustingBrush: boolean
isPanning: boolean
}
const mockStore: MockStore = reactive({
currentTool: Tools.MaskPen,
activeLayer: 'mask',
pointerZone: null,
brushVisible: true,
brushPreviewGradientVisible: false,
isAdjustingBrush: false,
isPanning: false
}) as MockStore
const mockBrushDrawing = {
startDrawing: vi.fn().mockResolvedValue(undefined),
handleDrawing: vi.fn().mockResolvedValue(undefined),
drawEnd: vi.fn().mockResolvedValue(undefined),
startBrushAdjustment: vi.fn().mockResolvedValue(undefined),
handleBrushAdjustment: vi.fn().mockResolvedValue(undefined)
}
const mockCanvasTools = {
paintBucketFill: vi.fn(),
colorSelectFill: vi.fn().mockResolvedValue(undefined),
clearLastColorSelectPoint: vi.fn()
}
const mockCoordinateTransform = {
screenToCanvas: vi.fn((p: { x: number; y: number }) => ({
x: p.x * 2,
y: p.y * 2
})),
canvasToScreen: vi.fn()
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
vi.mock('@/composables/maskeditor/useBrushDrawing', () => ({
useBrushDrawing: vi.fn(() => mockBrushDrawing)
}))
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
useCanvasTools: vi.fn(() => mockCanvasTools)
}))
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
useCoordinateTransform: vi.fn(() => mockCoordinateTransform)
}))
vi.mock('@/scripts/app', () => ({
app: {
extensionManager: {
setting: {
get: vi.fn((key: string) => {
if (key === 'Comfy.MaskEditor.UseDominantAxis') return false
if (key === 'Comfy.MaskEditor.BrushAdjustmentSpeed') return 1
return undefined
})
}
}
}
}))
const mockKeyboard = {
isKeyDown: vi.fn().mockReturnValue(false),
addListeners: vi.fn(),
removeListeners: vi.fn()
}
const mockPanZoom = {
initializeCanvasPanZoom: vi.fn(),
handlePanStart: vi.fn(),
handlePanMove: vi.fn().mockResolvedValue(undefined),
handleTouchStart: vi.fn(),
handleTouchMove: vi.fn(),
handleTouchEnd: vi.fn(),
updateCursorPosition: vi.fn(),
zoom: vi.fn(),
invalidatePanZoom: vi.fn(),
addPenPointerId: vi.fn(),
removePenPointerId: vi.fn()
}
const pointerEvent = (
init: Partial<PointerEvent> & { pointerType?: string }
): PointerEvent => {
return {
preventDefault: vi.fn(),
pointerId: 1,
pointerType: 'mouse',
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
offsetX: 0,
offsetY: 0,
altKey: false,
...init
} as unknown as PointerEvent
}
let scope: EffectScope | null = null
const setup = (): ReturnType<typeof useToolManager> => {
scope = effectScope()
return scope.run(() =>
useToolManager(
mockKeyboard as unknown as Parameters<typeof useToolManager>[0],
mockPanZoom as unknown as Parameters<typeof useToolManager>[1]
)
)!
}
describe('useToolManager', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore.currentTool = Tools.MaskPen
mockStore.activeLayer = 'mask'
mockStore.pointerZone = document.createElement('div')
mockStore.brushVisible = true
mockStore.brushPreviewGradientVisible = false
mockStore.isAdjustingBrush = false
mockStore.isPanning = false
mockKeyboard.isKeyDown.mockReturnValue(false)
})
afterEach(() => {
scope?.stop()
scope = null
})
describe('useBrushDrawing factory', () => {
it('should construct useBrushDrawing with settings from the extension manager', () => {
setup()
expect(useBrushDrawing).toHaveBeenCalledWith({
useDominantAxis: false,
brushAdjustmentSpeed: 1
})
})
})
describe('switchTool', () => {
it('should set the current tool on the store', () => {
const tm = setup()
tm.switchTool(Tools.Eraser)
expect(mockStore.currentTool).toBe(Tools.Eraser)
})
it('should update activeLayer to "rgb" when switching to PaintPen', () => {
const tm = setup()
tm.switchTool(Tools.PaintPen)
expect(mockStore.activeLayer).toBe('rgb')
})
it('should update activeLayer to "mask" when switching to MaskPen', () => {
const tm = setup()
mockStore.activeLayer = 'rgb'
tm.switchTool(Tools.MaskPen)
expect(mockStore.activeLayer).toBe('mask')
})
it('should set custom cursor and hide brush for MaskBucket', () => {
const tm = setup()
tm.switchTool(Tools.MaskBucket)
expect(mockStore.brushVisible).toBe(false)
expect(mockStore.pointerZone!.style.cursor).toContain('paintBucket.png')
})
it('should reset cursor to "none" and show brush for tools without custom cursor', () => {
const tm = setup()
tm.switchTool(Tools.MaskBucket)
expect(mockStore.brushVisible).toBe(false)
tm.switchTool(Tools.MaskPen)
expect(mockStore.brushVisible).toBe(true)
expect(mockStore.pointerZone!.style.cursor).toBe('none')
})
it('should not touch cursor when pointerZone is missing', () => {
const tm = setup()
mockStore.pointerZone = null
expect(() => tm.switchTool(Tools.MaskBucket)).not.toThrow()
})
})
describe('setActiveLayer', () => {
it('should switch from mask-only tool to PaintPen when activating rgb layer', () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
tm.setActiveLayer('rgb')
expect(mockStore.activeLayer).toBe('rgb')
expect(mockStore.currentTool).toBe(Tools.PaintPen)
})
it('should switch from PaintPen to MaskPen when activating mask layer', () => {
const tm = setup()
mockStore.currentTool = Tools.PaintPen
tm.setActiveLayer('mask')
expect(mockStore.activeLayer).toBe('mask')
expect(mockStore.currentTool).toBe(Tools.MaskPen)
})
it('should leave a non-mask-only tool alone when activating rgb', () => {
const tm = setup()
mockStore.currentTool = Tools.Eraser
tm.setActiveLayer('rgb')
expect(mockStore.currentTool).toBe(Tools.Eraser)
})
})
describe('updateCursor', () => {
it('should hide brush and set custom cursor for tools that define one', () => {
const tm = setup()
mockStore.currentTool = Tools.MaskColorFill
tm.updateCursor()
expect(mockStore.brushVisible).toBe(false)
expect(mockStore.pointerZone!.style.cursor).toContain('colorSelect.png')
expect(mockStore.brushPreviewGradientVisible).toBe(false)
})
it('should show brush and "none" cursor for tools without a custom cursor', () => {
const tm = setup()
mockStore.currentTool = Tools.MaskPen
tm.updateCursor()
expect(mockStore.brushVisible).toBe(true)
expect(mockStore.pointerZone!.style.cursor).toBe('none')
})
})
describe('currentTool watcher', () => {
it('should clear last color-select point when switching away from MaskColorFill', async () => {
setup()
mockStore.currentTool = Tools.MaskColorFill
await nextTick()
mockCanvasTools.clearLastColorSelectPoint.mockClear()
mockStore.currentTool = Tools.MaskPen
await nextTick()
expect(mockCanvasTools.clearLastColorSelectPoint).toHaveBeenCalledTimes(1)
})
it('should not clear color-select point when switching to MaskColorFill', async () => {
setup()
mockStore.currentTool = Tools.MaskPen
await nextTick()
mockCanvasTools.clearLastColorSelectPoint.mockClear()
mockStore.currentTool = Tools.MaskColorFill
await nextTick()
expect(mockCanvasTools.clearLastColorSelectPoint).not.toHaveBeenCalled()
})
})
describe('handlePointerDown', () => {
it('should ignore touch pointers entirely', async () => {
const tm = setup()
await tm.handlePointerDown(pointerEvent({ pointerType: 'touch' }))
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
expect(mockPanZoom.handlePanStart).not.toHaveBeenCalled()
expect(mockPanZoom.addPenPointerId).not.toHaveBeenCalled()
})
it('should register pen pointer id then continue tool routing', async () => {
const tm = setup()
await tm.handlePointerDown(
pointerEvent({
pointerType: 'pen',
button: 0,
buttons: 1,
pointerId: 7
})
)
expect(mockPanZoom.addPenPointerId).toHaveBeenCalledWith(7)
})
it('should start panning on middle mouse button (buttons===4)', async () => {
const tm = setup()
await tm.handlePointerDown(pointerEvent({ buttons: 4 }))
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
expect(mockStore.brushVisible).toBe(false)
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
it('should start panning on left button + space held', async () => {
const tm = setup()
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
await tm.handlePointerDown(pointerEvent({ buttons: 1 }))
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
it('should start drawing for MaskPen on left button', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskPen
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
})
it('should start drawing for PaintPen on left button', async () => {
const tm = setup()
mockStore.currentTool = Tools.PaintPen
await tm.handlePointerDown(pointerEvent({ button: 0, buttons: 1 }))
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
})
it('should continue drawing for PaintPen when a non-left button fires while left is held', async () => {
const tm = setup()
mockStore.currentTool = Tools.PaintPen
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 1 }))
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
it('should call paintBucketFill on MaskBucket left click using transformed coords', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
await tm.handlePointerDown(
pointerEvent({ button: 0, offsetX: 50, offsetY: 25 })
)
expect(mockCoordinateTransform.screenToCanvas).toHaveBeenCalledWith({
x: 50,
y: 25
})
expect(mockCanvasTools.paintBucketFill).toHaveBeenCalledWith({
x: 100,
y: 50
})
})
it('should call colorSelectFill on MaskColorFill left click', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskColorFill
await tm.handlePointerDown(
pointerEvent({ button: 0, offsetX: 10, offsetY: 20 })
)
expect(mockCanvasTools.colorSelectFill).toHaveBeenCalledWith({
x: 20,
y: 40
})
})
it('should start brush adjustment on alt + right-click', async () => {
const tm = setup()
await tm.handlePointerDown(
pointerEvent({ altKey: true, button: 2, buttons: 2 })
)
expect(mockStore.isAdjustingBrush).toBe(true)
expect(mockBrushDrawing.startBrushAdjustment).toHaveBeenCalled()
})
it('should start drawing on right-click for drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.Eraser
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
expect(mockBrushDrawing.startDrawing).toHaveBeenCalledTimes(1)
})
it('should not start drawing for non-drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
await tm.handlePointerDown(pointerEvent({ button: 2, buttons: 2 }))
expect(mockBrushDrawing.startDrawing).not.toHaveBeenCalled()
})
})
describe('handlePointerMove', () => {
it('should ignore touch pointers', async () => {
const tm = setup()
await tm.handlePointerMove(pointerEvent({ pointerType: 'touch' }))
expect(mockPanZoom.updateCursorPosition).not.toHaveBeenCalled()
})
it('should always update cursor position for non-touch pointers', async () => {
const tm = setup()
await tm.handlePointerMove(pointerEvent({ clientX: 30, clientY: 40 }))
expect(mockPanZoom.updateCursorPosition).toHaveBeenCalledWith({
x: 30,
y: 40
})
})
it('should pan on middle button drag', async () => {
const tm = setup()
await tm.handlePointerMove(pointerEvent({ buttons: 4 }))
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should pan on left button + space drag', async () => {
const tm = setup()
mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ')
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
})
it('should ignore drawing for non-drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskBucket
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should adjust brush on alt + right-drag while adjusting', async () => {
const tm = setup()
mockStore.isAdjustingBrush = true
mockStore.currentTool = Tools.MaskPen
await tm.handlePointerMove(pointerEvent({ altKey: true, buttons: 2 }))
expect(mockBrushDrawing.handleBrushAdjustment).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should call handleDrawing on left or right drag for drawing tools', async () => {
const tm = setup()
mockStore.currentTool = Tools.MaskPen
await tm.handlePointerMove(pointerEvent({ buttons: 1 }))
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(1)
await tm.handlePointerMove(pointerEvent({ buttons: 2 }))
expect(mockBrushDrawing.handleDrawing).toHaveBeenCalledTimes(2)
})
})
describe('handlePointerUp', () => {
it('should reset panning and brush state', async () => {
const tm = setup()
mockStore.isPanning = true
mockStore.brushVisible = false
mockStore.isAdjustingBrush = true
await tm.handlePointerUp(pointerEvent({}))
expect(mockStore.isPanning).toBe(false)
expect(mockStore.brushVisible).toBe(true)
expect(mockStore.isAdjustingBrush).toBe(false)
expect(mockBrushDrawing.drawEnd).toHaveBeenCalled()
})
it('should remove pen pointer id when pointerType is "pen"', async () => {
const tm = setup()
await tm.handlePointerUp(
pointerEvent({ pointerType: 'pen', pointerId: 12 })
)
expect(mockPanZoom.removePenPointerId).toHaveBeenCalledWith(12)
})
it('should bail out before drawEnd for touch pointers', async () => {
const tm = setup()
await tm.handlePointerUp(pointerEvent({ pointerType: 'touch' }))
expect(mockBrushDrawing.drawEnd).not.toHaveBeenCalled()
})
})
})

View File

@@ -86,6 +86,129 @@ describe('useNodeDragAndDrop', () => {
expect(isDragging).toBe(false)
})
describe('claimEvent flag', () => {
function createClaimableEvent(
options: Parameters<typeof createDragEvent>[0]
) {
const event = createDragEvent(options)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
Object.assign(event, { preventDefault, stopPropagation })
return { event, preventDefault, stopPropagation }
}
it('claims the event synchronously before awaiting onDrop for valid file drops', async () => {
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.png')]
})
const onDrop = vi.fn().mockImplementation(async () => {
// By the time onDrop runs, the event must already be claimed —
// claiming after the await would let document fallback handlers fire.
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(stopPropagation).toHaveBeenCalledTimes(1)
return []
})
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(true)
expect(onDrop).toHaveBeenCalledTimes(1)
})
it('does not claim the event when files are filtered out', async () => {
const node = createNode()
useNodeDragAndDrop(node, {
onDrop: vi.fn().mockResolvedValue([]),
fileFilter: (file) => file.type === 'image/png'
})
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.jpg', 'image/jpeg')]
})
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('claims the event for same-origin uri drops before fetching', async () => {
const { event, preventDefault, stopPropagation } = createClaimableEvent({
uri: `${location.origin}/api/file?filename=uri.png`,
types: ['text/uri-list']
})
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(stopPropagation).toHaveBeenCalledTimes(1)
return fromAny<Response, unknown>({
ok: true,
blob: vi
.fn()
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
})
})
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(true)
})
it('does not claim the event for cross-origin uri drops', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent({
uri: 'https://example.com/api/file?filename=uri.png',
types: ['text/uri-list']
})
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('does not claim the event when drop has no files and no uri', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent(
{}
)
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('does not claim the event when claimEvent is omitted', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.png')]
})
const result = await node.onDragDrop?.(event)
expect(result).toBe(true)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
})
it('onDragDrop calls onDrop with filtered files', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const node = createNode()

View File

@@ -47,11 +47,15 @@ export const useNodeDragAndDrop = <T>(
const installedDragOver = isDraggingFiles
node.onDragOver = installedDragOver
const installedDragDrop = async function (e: DragEvent) {
const installedDragDrop = async function (e: DragEvent, claimEvent = false) {
if (!isDraggingValidFiles(e)) return false
const files = filterFiles(e.dataTransfer!.files)
if (files.length) {
if (claimEvent) {
e.preventDefault()
e.stopPropagation()
}
await onDrop(files)
return true
}
@@ -59,6 +63,11 @@ export const useNodeDragAndDrop = <T>(
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
if (!uri || uri.origin !== location.origin) return false
if (claimEvent) {
e.preventDefault()
e.stopPropagation()
}
try {
const resp = await fetch(uri)
const fileName = uri?.searchParams?.get('filename')

View File

@@ -241,6 +241,259 @@ describe('Load3d', () => {
})
})
describe('viewport wiring', () => {
it('isActive ORs the activity flags through isLoad3dActive', () => {
Object.assign(ctx.load3d, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true,
animationManager: { isAnimationPlaying: false, dispose: vi.fn() },
recordingManager: { getIsRecording: vi.fn(() => false) }
})
expect(ctx.load3d.isActive()).toBe(false)
;(ctx.load3d as { STATUS_MOUSE_ON_NODE: boolean }).STATUS_MOUSE_ON_NODE =
true
expect(ctx.load3d.isActive()).toBe(true)
})
it('handleResize letterboxes the renderer when a target aspect ratio is set', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 600,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setSize = vi.fn()
const cameraResize = vi.fn()
const sceneResize = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: cameraResize },
sceneManager: { ...ctx.sceneManager, handleResize: sceneResize }
})
ctx.load3d.handleResize()
// Container 800x600, target aspect 2:1 → letterboxed render area 800x400
expect(setSize).toHaveBeenCalledWith(800, 600)
expect(cameraResize).toHaveBeenCalledWith(800, 400)
expect(sceneResize).toHaveBeenCalledWith(800, 400)
})
it('renderMainScene applies the letterboxed viewport and feeds aspect to the camera', () => {
const setViewport = vi.fn()
const setScissor = vi.fn()
const setScissorTest = vi.fn()
const setClearColor = vi.fn()
const clear = vi.fn()
const render = vi.fn()
const updateAspectRatio = vi.fn()
const renderBackground = vi.fn()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
const scene = {} as THREE.Scene
Object.assign(ctx.load3d, {
renderer: {
domElement: canvas,
setViewport,
setScissor,
setScissorTest,
setClearColor,
clear,
render
},
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, updateAspectRatio },
sceneManager: { ...ctx.sceneManager, renderBackground, scene }
})
ctx.load3d.renderMainScene()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(updateAspectRatio).toHaveBeenCalledWith(2)
expect(setScissorTest).toHaveBeenCalledWith(true)
expect(render).toHaveBeenCalledWith(scene, ctx.cameraManager.activeCamera)
})
it('setBackgroundImage updates background size with letterbox dimensions when a texture is loaded', async () => {
const updateBackgroundSize = vi.fn()
const setBackgroundImage = vi.fn().mockResolvedValue(undefined)
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
Object.assign(ctx.load3d, {
renderer: { domElement: canvas },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
sceneManager: {
...ctx.sceneManager,
setBackgroundImage,
updateBackgroundSize,
backgroundTexture: {},
backgroundMesh: {}
}
})
await ctx.load3d.setBackgroundImage('test.png')
expect(setBackgroundImage).toHaveBeenCalledWith('test.png')
// Container 800x600, target aspect 2:1 → letterbox render area 800x400
const args = updateBackgroundSize.mock.calls[0]
expect(args[2]).toBe(800)
expect(args[3]).toBe(400)
})
})
describe('render loop wiring', () => {
it('startAnimation registers a render loop whose tick body runs the per-frame managers when active', () => {
const animationUpdate = vi.fn()
const viewHelperUpdate = vi.fn()
const viewHelperRender = vi.fn()
const controlsUpdate = vi.fn()
const renderMainScene = vi.fn()
const resetViewport = vi.fn()
Object.assign(ctx.load3d, {
STATUS_MOUSE_ON_NODE: true,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: false,
clock: new THREE.Clock(),
animationManager: {
update: animationUpdate,
isAnimationPlaying: false,
dispose: vi.fn()
},
viewHelperManager: {
update: viewHelperUpdate,
viewHelper: { render: viewHelperRender }
},
controlsManager: { update: controlsUpdate },
recordingManager: { getIsRecording: vi.fn(() => false) },
renderMainScene,
resetViewport,
renderer: {}
})
;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation()
const loop = (ctx.load3d as unknown as { renderLoop: { stop(): void } })
.renderLoop
expect(loop).not.toBeNull()
expect(typeof loop.stop).toBe('function')
// The first loop() ran synchronously; isActive() returned true
// (STATUS_MOUSE_ON_NODE), so the tick body executed once.
expect(animationUpdate).toHaveBeenCalledOnce()
expect(viewHelperUpdate).toHaveBeenCalledOnce()
expect(controlsUpdate).toHaveBeenCalledOnce()
expect(renderMainScene).toHaveBeenCalledOnce()
expect(resetViewport).toHaveBeenCalledOnce()
expect(viewHelperRender).toHaveBeenCalledOnce()
// Cancel the queued rAF so the test doesn't leak frames.
loop.stop()
})
it('remove() stops the active render loop and clears the handle', () => {
const stop = vi.fn()
const canvas = document.createElement('canvas')
Object.assign(ctx.load3d, {
renderLoop: { stop },
resizeObserver: null,
contextMenuAbortController: null,
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: canvas
},
sceneManager: { ...ctx.sceneManager, dispose: vi.fn() },
cameraManager: { ...ctx.cameraManager, dispose: vi.fn() },
controlsManager: { ...ctx.controlsManager, dispose: vi.fn() },
lightingManager: { dispose: vi.fn() },
hdriManager: { dispose: vi.fn() },
viewHelperManager: { dispose: vi.fn() },
loaderManager: { dispose: vi.fn() },
modelManager: { ...ctx.modelManager, dispose: vi.fn() },
recordingManager: { dispose: vi.fn() },
animationManager: { ...ctx.animationManager, dispose: vi.fn() },
gizmoManager: { ...ctx.gizmo, dispose: vi.fn() }
})
ctx.load3d.remove()
expect(stop).toHaveBeenCalledOnce()
expect(
(ctx.load3d as unknown as { renderLoop: unknown }).renderLoop
).toBeNull()
})
})
describe('adapter-driven kind queries', () => {
function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) {
const adapter = kind === null ? null : { kind }
Object.assign(ctx.load3d, {
loaderManager: { getCurrentAdapter: vi.fn(() => adapter) }
})
}
it('isSplatModel is true only when the current adapter kind is "splat"', () => {
makeWithAdapter('splat')
expect(ctx.load3d.isSplatModel()).toBe(true)
makeWithAdapter('mesh')
expect(ctx.load3d.isSplatModel()).toBe(false)
makeWithAdapter(null)
expect(ctx.load3d.isSplatModel()).toBe(false)
})
it('isPlyModel is true only when the current adapter kind is "pointCloud"', () => {
makeWithAdapter('pointCloud')
expect(ctx.load3d.isPlyModel()).toBe(true)
makeWithAdapter('mesh')
expect(ctx.load3d.isPlyModel()).toBe(false)
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -24,6 +22,10 @@ import type {
MaterialMode,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
function positionThumbnailCamera(
camera: THREE.PerspectiveCamera,
@@ -47,7 +49,7 @@ function positionThumbnailCamera(
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
protected animationFrameId: number | null = null
private renderLoop: RenderLoopHandle | null = null
private loadingPromise: Promise<void> | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
@@ -75,10 +77,7 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
@@ -214,69 +213,12 @@ class Load3d {
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
*/
private initContextMenu(): void {
const canvas = this.renderer.domElement
this.contextMenuAbortController = new AbortController()
const { signal } = this.contextMenuAbortController
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
this.rightMouseMoved = true
}
}
}
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const wasDragging =
this.rightMouseMoved ||
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
this.rightMouseMoved = false
if (wasDragging) {
return
}
e.preventDefault()
e.stopPropagation()
this.showNodeContextMenu(e)
}
canvas.addEventListener('mousedown', mousedownHandler, { signal })
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
}
private showNodeContextMenu(event: MouseEvent): void {
if (this.onContextMenuCallback) {
this.onContextMenuCallback(event)
}
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
@@ -354,22 +296,10 @@ class Load3d {
}
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
let offsetX: number = 0
let offsetY: number = 0
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
offsetX = (containerWidth - renderWidth) / 2
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
offsetY = (containerHeight - renderHeight) / 2
}
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
@@ -377,11 +307,10 @@ class Load3d {
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
const renderAspectRatio = renderWidth / renderHeight
this.cameraManager.updateAspectRatio(renderAspectRatio)
this.cameraManager.updateAspectRatio(width / height)
} else {
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
@@ -422,28 +351,23 @@ class Load3d {
}
private startAnimation(): void {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
if (!this.isActive()) {
return
}
this.renderMainScene()
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.resetViewport()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
}
animate()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
@@ -459,14 +383,14 @@ class Load3d {
}
isActive(): boolean {
return (
this.STATUS_MOUSE_ON_NODE ||
this.STATUS_MOUSE_ON_SCENE ||
this.STATUS_MOUSE_ON_VIEWER ||
this.isRecording() ||
!this.INITIAL_RENDER_DONE ||
this.animationManager.isAnimationPlaying
)
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
recording: this.isRecording(),
initialRenderDone: this.INITIAL_RENDER_DONE,
animationPlaying: this.animationManager.isAnimationPlaying
})
}
async exportModel(format: string): Promise<void> {
@@ -527,24 +451,16 @@ class Load3d {
const containerHeight = this.renderer.domElement.clientHeight
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
renderWidth,
renderHeight
width,
height
)
} else {
// No aspect ratio constraints: fill container
@@ -651,11 +567,11 @@ class Load3d {
}
isSplatModel(): boolean {
return this.modelManager.containsSplatMesh()
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
}
isPlyModel(): boolean {
return this.modelManager.originalModel instanceof THREE.BufferGeometry
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
}
clearModel(): void {
@@ -742,21 +658,14 @@ class Load3d {
}
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(renderWidth, renderHeight)
this.sceneManager.handleResize(renderWidth, renderHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
// No aspect ratio constraint: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
@@ -945,10 +854,8 @@ class Load3d {
this.resizeObserver = null
}
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
@@ -958,9 +865,8 @@ class Load3d {
})
canvas.dispatchEvent(event)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
this.renderLoop?.stop()
this.renderLoop = null
this.sceneManager.dispose()
this.cameraManager.dispose()

View File

@@ -0,0 +1,530 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
EventManagerInterface,
MaterialMode,
ModelManagerInterface
} from './interfaces'
import { LoaderManager } from './LoaderManager'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
function makeEventManagerStub() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
type ModelManagerStub = {
clearModel: ReturnType<typeof vi.fn>
setupModel: ReturnType<typeof vi.fn>
setOriginalModel: ReturnType<typeof vi.fn>
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
standardMaterial: THREE.MeshStandardMaterial
materialMode: MaterialMode
originalFileName: string | null
originalURL: string | null
}
function makeModelManagerStub(): ModelManagerStub {
return {
clearModel: vi.fn(),
setupModel: vi.fn().mockResolvedValue(undefined),
setOriginalModel: vi.fn(),
originalMaterials: new WeakMap(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode: 'original',
originalFileName: 'model',
originalURL: null
}
}
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
getPLYEngineMock: vi.fn<() => string>(),
addAlert: vi.fn()
}))
vi.mock('./MeshModelAdapter', () => ({
MeshModelAdapter: class {
readonly kind = 'mesh' as const
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
readonly capabilities = {}
load = meshLoad
}
}))
vi.mock('./PointCloudModelAdapter', () => ({
PointCloudModelAdapter: class {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities = {}
load = pointCloudLoad
},
getPLYEngine: () => getPLYEngineMock()
}))
vi.mock('./SplatModelAdapter', () => ({
SplatModelAdapter: class {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly capabilities = {}
load = splatLoad
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert })
}))
type LoaderManagerInternals = {
pickAdapter(extension: string): ModelAdapter | null
}
function makeLoaderManager() {
const modelManager = makeModelManagerStub()
const eventManager = makeEventManagerStub()
const lm = new LoaderManager(
modelManager as unknown as ConstructorParameters<typeof LoaderManager>[0],
eventManager
)
const internals = lm as unknown as LoaderManagerInternals
return {
lm,
modelManager,
eventManager,
pick: internals.pickAdapter.bind(lm)
}
}
describe('LoaderManager', () => {
beforeEach(() => {
vi.clearAllMocks()
getPLYEngineMock.mockReturnValue('three')
meshLoad.mockResolvedValue(null)
splatLoad.mockResolvedValue(null)
pointCloudLoad.mockResolvedValue(null)
})
describe('getCurrentAdapter', () => {
it('returns null before any model loads', () => {
const { lm } = makeLoaderManager()
expect(lm.getCurrentAdapter()).toBeNull()
})
it('exposes the picked adapter after a successful load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
})
it('resets to null at the start of a new load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
await lm.loadModel('api/view?filename=cube.xyz')
expect(lm.getCurrentAdapter()).toBeNull()
})
it('stays null when the adapter rejects', async () => {
const { lm } = makeLoaderManager()
// Seed with a previously-successful mesh load so we can prove a later
// failed splat load does not leave the splat adapter published.
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
splatLoad.mockRejectedValueOnce(new Error('boom'))
vi.spyOn(console, 'error').mockImplementation(() => {})
await lm.loadModel('api/view?filename=scan.splat')
expect(lm.getCurrentAdapter()).toBeNull()
})
it('stays null when the adapter resolves null (parse failure)', async () => {
const { lm } = makeLoaderManager()
pointCloudLoad.mockResolvedValueOnce(null)
await lm.loadModel('api/view?filename=scan.ply')
expect(lm.getCurrentAdapter()).toBeNull()
})
})
describe('loadModel ordering', () => {
it('keeps the old adapter current while clearModel runs (so future dispose hooks see it)', async () => {
const oldAdapter = {
kind: 'splat' as const,
extensions: ['splat'] as const,
capabilities: {
fitToViewer: false,
requiresMaterialRebuild: false,
gizmoTransform: false,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 5
},
load: vi.fn().mockResolvedValue(null)
} satisfies ModelAdapter
const modelManager = {
originalMaterials: new WeakMap(),
clearModel: vi.fn(),
setupModel: vi.fn()
} as unknown as ModelManagerInterface
const eventManager: EventManagerInterface = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
let adapterDuringClear: ModelAdapter | null | undefined
const lm = new LoaderManager(modelManager, eventManager, [oldAdapter])
// Prime the loader with an active adapter, then trigger a new load.
;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter =
oldAdapter
;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation(
() => {
adapterDuringClear = lm.getCurrentAdapter()
}
)
await lm.loadModel(
'api/view?type=input&subfolder=&filename=a.splat',
'a.splat'
)
expect(adapterDuringClear).toBe(oldAdapter)
})
})
describe('pickAdapter', () => {
it.each(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
(ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('mesh')
}
)
it.each(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
(ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('splat')
}
)
it('routes .ply to the point-cloud adapter for the default three engine', () => {
getPLYEngineMock.mockReturnValue('three')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
getPLYEngineMock.mockReturnValue('fastply')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
getPLYEngineMock.mockReturnValue('sparkjs')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('splat')
})
it('returns null for unknown extensions', () => {
const { pick } = makeLoaderManager()
expect(pick('xyz')).toBeNull()
expect(pick('')).toBeNull()
})
})
describe('loadModel', () => {
it('emits modelLoadingStart and records originalURL before dispatching', async () => {
const { lm, eventManager, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingStart',
null
)
expect(modelManager.originalURL).toBe('api/view?filename=cube.glb')
})
it('clears any existing model before routing to the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const order: string[] = []
modelManager.clearModel.mockImplementation(() => order.push('clear'))
meshLoad.mockImplementationOnce(async () => {
order.push('load')
return null
})
await lm.loadModel('api/view?filename=cube.glb')
expect(order).toEqual(['clear', 'load'])
})
it('derives originalFileName from an explicit originalFileName argument', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=ignored.glb', 'uploads/my-cube.glb')
expect(modelManager.originalFileName).toBe('my-cube')
})
it('derives originalFileName from the URL filename param when no override is given', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.originalFileName).toBe('cube')
})
it('falls back to "model" when the URL has no filename param', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?other=1')
expect(modelManager.originalFileName).toBe('model')
})
it('alerts when the file extension cannot be determined', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?other=1')
expect(addAlert).toHaveBeenCalledWith(
'toastMessages.couldNotDetermineFileType'
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
expect(meshLoad).not.toHaveBeenCalled()
})
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
meshLoad.mockResolvedValueOnce(loaded)
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.setupModel).toHaveBeenCalledWith(loaded)
})
it('skips setupModel when the adapter returns null', async () => {
const { lm, modelManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(null)
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.setupModel).not.toHaveBeenCalled()
})
it('emits modelLoadingEnd when the load completes', async () => {
const { lm, eventManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
})
it('forwards a decoded path and filename to the adapter', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel(
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
)
expect(meshLoad).toHaveBeenCalledWith(
expect.objectContaining({
setOriginalModel: expect.any(Function),
registerOriginalMaterial: expect.any(Function)
}),
'api/view?type=output&subfolder=nested%2Fdir&filename=',
'cube.glb'
)
})
it('defaults the path to type=input when no type param is given', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(meshLoad).toHaveBeenCalledWith(
expect.anything(),
'api/view?type=input&subfolder=&filename=',
'cube.glb'
)
})
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
getPLYEngineMock.mockReturnValue('sparkjs')
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=scan.ply')
expect(splatLoad).toHaveBeenCalled()
expect(pointCloudLoad).not.toHaveBeenCalled()
})
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
const { lm, eventManager } = makeLoaderManager()
const err = new Error('boom')
meshLoad.mockRejectedValueOnce(err)
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
expect(consoleError).toHaveBeenCalled()
})
it('discards the result of a stale load when a newer one has started', async () => {
const { lm, modelManager, eventManager } = makeLoaderManager()
let resolveFirst!: (value: THREE.Object3D) => void
const firstLoad = new Promise<THREE.Object3D>((r) => {
resolveFirst = r
})
const firstModel = new THREE.Object3D()
firstModel.name = 'first'
const secondModel = new THREE.Object3D()
secondModel.name = 'second'
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(secondModel)
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')
resolveFirst(firstModel)
await Promise.all([firstPromise, secondPromise])
expect(modelManager.setupModel).toHaveBeenCalledTimes(1)
expect(modelManager.setupModel).toHaveBeenCalledWith(secondModel)
const endEmits = eventManager.emitEvent.mock.calls.filter(
(call: unknown[]) => call[0] === 'modelLoadingEnd'
)
expect(endEmits).toHaveLength(1)
})
it('logs and drops the load when the URL is missing a filename param', async () => {
const { lm, modelManager } = makeLoaderManager()
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
await lm.loadModel('api/view?type=output', 'uploads/file.glb')
expect(consoleError).toHaveBeenCalledWith(
'Missing filename in URL:',
'api/view?type=output'
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('proxies setOriginalModel and registerOriginalMaterial through the load context', async () => {
const { lm, modelManager } = makeLoaderManager()
let capturedCtx: ModelLoadContext | undefined
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
capturedCtx = ctx
return new THREE.Object3D()
})
await lm.loadModel('api/view?filename=cube.glb')
const mesh = new THREE.Mesh(
new THREE.BufferGeometry(),
new THREE.MeshBasicMaterial()
)
const mat = new THREE.MeshStandardMaterial()
capturedCtx!.setOriginalModel(mesh)
capturedCtx!.registerOriginalMaterial(mesh, mat)
expect(modelManager.setOriginalModel).toHaveBeenCalledWith(mesh)
expect(modelManager.originalMaterials.get(mesh)).toBe(mat)
})
it('exposes modelManager.standardMaterial and materialMode via getters on the load context', async () => {
const { lm, modelManager } = makeLoaderManager()
modelManager.materialMode = 'wireframe'
let capturedCtx: ModelLoadContext | undefined
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
capturedCtx = ctx
return new THREE.Object3D()
})
await lm.loadModel('api/view?filename=cube.glb')
expect(capturedCtx!.standardMaterial).toBe(modelManager.standardMaterial)
expect(capturedCtx!.materialMode).toBe('wireframe')
})
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
const { lm, eventManager } = makeLoaderManager()
let rejectFirst!: (err: unknown) => void
const firstLoad = new Promise<THREE.Object3D>((_, r) => {
rejectFirst = r
})
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(new THREE.Object3D())
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')
rejectFirst(new Error('stale failure'))
await Promise.all([firstPromise, secondPromise])
expect(addAlert).not.toHaveBeenCalled()
const endEmits = eventManager.emitEvent.mock.calls.filter(
(call: unknown[]) => call[0] === 'modelLoadingEnd'
)
expect(endEmits).toHaveLength(1)
consoleError.mockRestore()
})
})
})

View File

@@ -1,60 +1,49 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
// Use pre-bundled worker module (has all dependencies included)
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type * as THREE from 'three'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import { MeshModelAdapter } from './MeshModelAdapter'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import {
type EventManagerInterface,
type LoaderManagerInterface,
type ModelManagerInterface
} from './interfaces'
import { FastPLYLoader } from './loader/FastPLYLoader'
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new PointCloudModelAdapter(),
new SplatModelAdapter()
]
}
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
plyLoader: PLYLoader
fastPlyLoader: FastPLYLoader
private modelManager: ModelManagerInterface
private eventManager: EventManagerInterface
private readonly modelManager: ModelManagerInterface
private readonly eventManager: EventManagerInterface
private readonly adapters: ModelAdapter[]
private currentLoadId: number = 0
private _currentAdapter: ModelAdapter | null = null
constructor(
modelManager: ModelManagerInterface,
eventManager: EventManagerInterface
eventManager: EventManagerInterface,
adapters?: readonly ModelAdapter[]
) {
this.modelManager = modelManager
this.eventManager = eventManager
this.adapters = adapters ? [...adapters] : defaultAdapters()
}
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader2Parallel()
// Set worker URL for Vite compatibility
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
this.plyLoader = new PLYLoader()
this.fastPlyLoader = new FastPLYLoader()
getCurrentAdapter(): ModelAdapter | null {
return this._currentAdapter
}
init(): void {}
@@ -68,6 +57,7 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager.emitEvent('modelLoadingStart', null)
this.modelManager.clearModel()
this._currentAdapter = null
this.modelManager.originalURL = url
@@ -80,12 +70,9 @@ export class LoaderManager implements LoaderManagerInterface {
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
if (filename) {
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
} else {
this.modelManager.originalFileName = 'model'
}
this.modelManager.originalFileName = filename
? filename.split('.')[0] || 'model'
: 'model'
}
if (!fileExtension) {
@@ -93,19 +80,21 @@ export class LoaderManager implements LoaderManagerInterface {
return
}
const model = await this.loadModelInternal(url, fileExtension)
const result = await this.loadModelInternal(url, fileExtension)
if (loadId !== this.currentLoadId) {
return
}
if (model) {
await this.modelManager.setupModel(model)
if (result && result.model) {
this._currentAdapter = result.adapter
await this.modelManager.setupModel(result.model)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
} catch (error) {
if (loadId === this.currentLoadId) {
this._currentAdapter = null
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
@@ -113,26 +102,50 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.extensions.includes(extension)
)
if (!match) return null
// PLY may be routed through the splat adapter when the PLYEngine setting
// is sparkjs. Only honor the routing when both adapters are registered.
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
if (splat) return splat
}
return match
}
private createLoadContext(): ModelLoadContext {
const mm = this.modelManager
return {
setOriginalModel: (model) => mm.setOriginalModel(model),
registerOriginalMaterial: (mesh, material) =>
mm.originalMaterials.set(mesh, material),
get standardMaterial() {
return mm.standardMaterial
},
get materialMode() {
return mm.materialMode
}
}
}
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<THREE.Object3D | null> {
let model: THREE.Object3D | null = null
): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
if (!filename) {
console.error('Missing filename in URL:', url)
return null
}
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
const subfolder = params.get('subfolder') ?? ''
const path =
'api/view?type=' +
loadRootFolder +
@@ -140,217 +153,10 @@ export class LoaderManager implements LoaderManagerInterface {
encodeURIComponent(subfolder) +
'&filename='
switch (fileExtension) {
case 'stl':
this.stlLoader.setPath(path)
const geometry = await this.stlLoader.loadAsync(filename)
this.modelManager.setOriginalModel(geometry)
geometry.computeVertexNormals()
const adapter = this.pickAdapter(fileExtension)
if (!adapter) return null
const mesh = new THREE.Mesh(
geometry,
this.modelManager.standardMaterial
)
const group = new THREE.Group()
group.add(mesh)
model = group
break
case 'fbx':
this.fbxLoader.setPath(path)
const fbxModel = await this.fbxLoader.loadAsync(filename)
this.modelManager.setOriginalModel(fbxModel)
model = fbxModel
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break
case 'obj':
if (this.modelManager.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)
const mtlFileName = filename.replace(/\.obj$/, '.mtl')
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
const objUrl = path + encodeURIComponent(filename)
model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
}
})
break
case 'gltf':
case 'glb':
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
this.modelManager.setOriginalModel(gltf)
model = gltf.scene
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break
case 'ply':
model = await this.loadPLY(path, filename)
break
case 'spz':
case 'splat':
case 'ksplat':
model = await this.loadSplat(path, filename)
break
}
return model
}
private async fetchModelData(path: string, filename: string) {
const route =
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}
private async loadSplat(
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await this.fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
this.modelManager.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
private async loadPLY(
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
if (plyEngine === 'sparkjs') {
return this.loadSplat(path, filename)
}
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
const arrayBuffer = await this.fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
let plyGeometry: THREE.BufferGeometry
if (isASCII && plyEngine === 'fastply') {
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
} else {
this.plyLoader.setPath(path)
plyGeometry = this.plyLoader.parse(arrayBuffer)
}
this.modelManager.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
const materialMode = this.modelManager.materialMode
// Use Points rendering for pointCloud mode (better for point clouds)
if (materialMode === 'pointCloud') {
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
this.modelManager.originalMaterials.set(
plyPoints as unknown as THREE.Mesh,
pointMaterial
)
const plyGroup = new THREE.Group()
plyGroup.add(plyPoints)
return plyGroup
}
// Use Mesh rendering for other modes
let plyMaterial: THREE.Material
if (hasVertexColors) {
plyMaterial = new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
} else {
plyMaterial = this.modelManager.standardMaterial.clone()
plyMaterial.side = THREE.DoubleSide
}
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
const plyGroup = new THREE.Group()
plyGroup.add(plyMesh)
return plyGroup
const model = await adapter.load(this.createLoadContext(), path, filename)
return { adapter, model }
}
}

View File

@@ -0,0 +1,302 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MeshModelAdapter } from './MeshModelAdapter'
import type { ModelLoadContext } from './ModelAdapter'
const stlLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<THREE.BufferGeometry>>()
}
const fbxLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<THREE.Object3D>>()
}
const gltfLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<{ scene: THREE.Object3D }>>()
}
const mtlLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<{ preload: () => void }>>()
}
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
vi.mock('three/examples/jsm/loaders/STLLoader', () => ({
STLLoader: class {
setPath = stlLoaderStub.setPath
loadAsync = stlLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/FBXLoader', () => ({
FBXLoader: class {
setPath = fbxLoaderStub.setPath
loadAsync = fbxLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/GLTFLoader', () => ({
GLTFLoader: class {
setPath = gltfLoaderStub.setPath
loadAsync = gltfLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/MTLLoader', () => ({
MTLLoader: class {
setPath = mtlLoaderStub.setPath
loadAsync = mtlLoaderStub.loadAsync
}
}))
vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
addMaterialsFromMtlLoader: vi.fn().mockReturnValue([])
}
}))
vi.mock('wwobjloader2/bundle/worker/module?url', () => ({
default: 'mock-worker-url'
}))
function makeContext(
materialMode: ModelLoadContext['materialMode'] = 'original'
): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode
}
}
function makeFbxLikeGroup(): THREE.Group {
const group = new THREE.Group()
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
group.add(mesh)
return group
}
describe('MeshModelAdapter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('identity', () => {
it('identifies as a mesh adapter with full capabilities', () => {
const adapter = new MeshModelAdapter()
expect(adapter.kind).toBe('mesh')
expect(adapter.capabilities.fitToViewer).toBe(true)
expect(adapter.capabilities.requiresMaterialRebuild).toBe(false)
expect(adapter.capabilities.gizmoTransform).toBe(true)
expect(adapter.capabilities.lighting).toBe(true)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'normal',
'wireframe'
])
})
it('handles the expected mesh extensions', () => {
const adapter = new MeshModelAdapter()
expect([...adapter.extensions]).toEqual([
'stl',
'fbx',
'obj',
'gltf',
'glb'
])
})
})
describe('dispatch fallbacks', () => {
it('returns null when the filename extension belongs to another adapter', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'cloud.ply')
expect(result).toBeNull()
})
it('returns null for an unknown extension', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'data.xyz')
expect(result).toBeNull()
})
it('returns null for a filename without an extension', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'noextension')
expect(result).toBeNull()
})
})
describe('STL loader path', () => {
it('loads STL geometry and wraps it in a Group with a Mesh child', async () => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
)
stlLoaderStub.loadAsync.mockResolvedValue(geometry)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'model.stl')
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
})
})
describe('FBX loader path', () => {
it('loads an FBX model and registers its mesh materials', async () => {
const fbxModel = makeFbxLikeGroup()
fbxLoaderStub.loadAsync.mockResolvedValue(fbxModel)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'rig.fbx')
expect(fbxLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(fbxModel)
})
it('disables frustum culling on SkinnedMesh children', async () => {
const group = new THREE.Group()
const skinned = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
skinned.frustumCulled = true
group.add(skinned)
fbxLoaderStub.loadAsync.mockResolvedValue(group)
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'animated.fbx')
expect(skinned.frustumCulled).toBe(false)
})
})
describe('OBJ loader path', () => {
it('attempts the MTL sidecar in original material mode', async () => {
mtlLoaderStub.loadAsync.mockResolvedValue({ preload: vi.fn() })
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
await adapter.load(makeContext('original'), '/api/view/', 'cube.obj')
expect(mtlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(mtlLoaderStub.loadAsync).toHaveBeenCalledWith('cube.mtl')
expect(objLoaderStub.setMaterials).toHaveBeenCalled()
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
})
it('swallows MTL load errors and continues without materials', async () => {
mtlLoaderStub.loadAsync.mockRejectedValue(new Error('no mtl'))
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const result = await adapter.load(
makeContext('original'),
'/api/view/',
'cube.obj'
)
expect(result).toBeInstanceOf(THREE.Group)
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
})
it('skips the MTL attempt for non-original material modes', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
await adapter.load(makeContext('wireframe'), '/api/view/', 'cube.obj')
expect(mtlLoaderStub.loadAsync).not.toHaveBeenCalled()
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
})
it('registers materials for each mesh child', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'cube.obj')
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
})
describe('GLTF loader path', () => {
it('loads a .glb and returns the scene with vertex normals computed', async () => {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
const computeNormals = vi.spyOn(mesh.geometry, 'computeVertexNormals')
const scene = new THREE.Group()
scene.add(mesh)
const gltf = { scene }
gltfLoaderStub.loadAsync.mockResolvedValue(gltf)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'scene.glb')
expect(gltfLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.glb')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
expect(computeNormals).toHaveBeenCalled()
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(scene)
})
it('also handles .gltf filenames', async () => {
gltfLoaderStub.loadAsync.mockResolvedValue({ scene: new THREE.Group() })
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'scene.gltf')
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.gltf')
})
it('disables frustum culling on SkinnedMesh children inside the scene', async () => {
const scene = new THREE.Group()
const skinned = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
skinned.frustumCulled = true
scene.add(skinned)
gltfLoaderStub.loadAsync.mockResolvedValue({ scene })
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'rigged.glb')
expect(skinned.frustumCulled).toBe(false)
})
})
})

View File

@@ -0,0 +1,155 @@
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
// Use pre-bundled worker module (has all dependencies included).
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds.
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
export class MeshModelAdapter implements ModelAdapter {
readonly kind = 'mesh' as const
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}
private readonly gltfLoader = new GLTFLoader()
private readonly objLoader: OBJLoader2Parallel
private readonly mtlLoader = new MTLLoader()
private readonly fbxLoader = new FBXLoader()
private readonly stlLoader = new STLLoader()
constructor() {
this.objLoader = new OBJLoader2Parallel()
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const extension = filename.split('.').pop()?.toLowerCase()
switch (extension) {
case 'stl':
return this.loadSTL(ctx, path, filename)
case 'fbx':
return this.loadFBX(ctx, path, filename)
case 'obj':
return this.loadOBJ(ctx, path, filename)
case 'gltf':
case 'glb':
return this.loadGLTF(ctx, path, filename)
}
return null
}
private async loadSTL(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.stlLoader.setPath(path)
const geometry = await this.stlLoader.loadAsync(filename)
ctx.setOriginalModel(geometry)
geometry.computeVertexNormals()
const mesh = new THREE.Mesh(geometry, ctx.standardMaterial)
const group = new THREE.Group()
group.add(mesh)
return group
}
private async loadFBX(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.fbxLoader.setPath(path)
const fbxModel = await this.fbxLoader.loadAsync(filename)
ctx.setOriginalModel(fbxModel)
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
ctx.registerOriginalMaterial(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
return fbxModel
}
private async loadOBJ(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)
const mtlFileName = filename.replace(/\.obj$/i, '.mtl')
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
const objUrl = path + encodeURIComponent(filename)
const model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
ctx.registerOriginalMaterial(child, child.material)
}
})
return model
}
private async loadGLTF(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
ctx.setOriginalModel(gltf)
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
ctx.registerOriginalMaterial(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
return gltf.scene
}
}

View File

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { DEFAULT_MODEL_CAPABILITIES, fetchModelData } from './ModelAdapter'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
}
}))
describe('DEFAULT_MODEL_CAPABILITIES', () => {
it('enables fit-to-viewer / gizmo / lighting / export by default', () => {
expect(DEFAULT_MODEL_CAPABILITIES.fitToViewer).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.requiresMaterialRebuild).toBe(false)
expect(DEFAULT_MODEL_CAPABILITIES.gizmoTransform).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.lighting).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
'original',
'normal',
'wireframe'
])
})
})
describe('fetchModelData', () => {
const mockFetchApi = vi.mocked(api.fetchApi)
beforeEach(() => {
mockFetchApi.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the arrayBuffer on a successful response', async () => {
const buf = new ArrayBuffer(8)
mockFetchApi.mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: vi.fn().mockResolvedValue(buf)
} as unknown as Response)
const result = await fetchModelData('api/view?...&filename=', 'model.glb')
expect(result).toBe(buf)
})
it('throws with status code when the response is not ok', async () => {
mockFetchApi.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await expect(
fetchModelData('api/view?type=input&subfolder=&filename=', 'missing.glb')
).rejects.toThrow('Failed to fetch model: 404')
})
it('strips the leading api/ prefix and encodes the filename', async () => {
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
} as unknown as Response)
await fetchModelData(
'api/view?type=input&subfolder=&filename=',
'a b c.ply'
)
expect(mockFetchApi).toHaveBeenCalledWith(
'/view?type=input&subfolder=&filename=a%20b%20c.ply'
)
})
it('prepends a single slash when the path has no api/ prefix', async () => {
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
} as unknown as Response)
await fetchModelData('custom?filename=', 'scene.splat')
expect(mockFetchApi).toHaveBeenCalledWith('/custom?filename=scene.splat')
})
})

View File

@@ -0,0 +1,88 @@
import type * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { api } from '@/scripts/api'
import type { MaterialMode } from './interfaces'
export interface ModelLoadContext {
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
registerOriginalMaterial(
mesh: THREE.Mesh,
material: THREE.Material | THREE.Material[]
): void
readonly standardMaterial: THREE.MeshStandardMaterial
readonly materialMode: MaterialMode
}
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**
* Whether auto-normalize/centering on load and the explicit fit-to-viewer
* action should run. Splats render self-sized and are placed at a fixed
* camera distance instead.
*/
fitToViewer: boolean
/**
* Whether a material mode change must rebuild the scene object instead of
* traversing the existing mesh tree. True for point-cloud PLY (Mesh <->
* Points swap); false for regular meshes and self-rendering splats.
*/
requiresMaterialRebuild: boolean
/**
* Whether the gizmo transform UI (translate/rotate/scale) should be
* exposed for this model type. False for adapters whose already-normalized
* output makes user transforms meaningless (PLY point cloud).
*/
gizmoTransform: boolean
/** Whether scene-lighting controls apply. False for self-lit formats. */
lighting: boolean
/** Whether the model can be exported (GLB/OBJ/STL). */
exportable: boolean
/**
* Material modes offered in the UI for this format. An empty array hides
* the material-mode dropdown entirely.
*/
materialModes: readonly MaterialMode[]
/**
* World-space target size along the largest dimension after
* fit-to-viewer normalization. Controls how large the model ends up
* relative to the 20-unit scene grid; splats use a larger value so they
* don't shrink to a quarter of the floor.
*/
fitTargetSize: number
}
export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}
export interface ModelAdapter {
readonly kind: ModelAdapterKind
readonly extensions: readonly string[]
readonly capabilities: ModelAdapterCapabilities
load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null>
}
export async function fetchModelData(
path: string,
filename: string
): Promise<ArrayBuffer> {
const route = '/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}

View File

@@ -0,0 +1,116 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelLoadContext } from './ModelAdapter'
import * as ModelAdapterModule from './ModelAdapter'
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
const mockSettingGet = vi.fn<(key: string) => unknown>()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: mockSettingGet })
}))
vi.mock('@/scripts/metadata/ply', () => ({
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
}))
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
PLYLoader: class {
setPath = vi.fn()
parse = vi.fn(() => makePLYGeometry(false))
}
}))
vi.mock('./loader/FastPLYLoader', () => ({
FastPLYLoader: class {
parse = vi.fn(() => makePLYGeometry(false))
}
}))
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
)
if (withColors) {
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
}
return geometry
}
function makeContext(
materialMode: ModelLoadContext['materialMode'] = 'original'
): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode
}
}
describe('PointCloudModelAdapter', () => {
beforeEach(() => {
mockSettingGet.mockReset()
})
describe('identity', () => {
it('handles the ply extension', () => {
const adapter = new PointCloudModelAdapter()
expect([...adapter.extensions]).toEqual(['ply'])
})
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
const adapter = new PointCloudModelAdapter()
expect(adapter.kind).toBe('pointCloud')
expect(adapter.capabilities.fitToViewer).toBe(false)
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
expect(adapter.capabilities.gizmoTransform).toBe(false)
expect(adapter.capabilities.lighting).toBe(true)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'pointCloud',
'normal',
'wireframe'
])
})
})
describe('load', () => {
beforeEach(() => {
mockSettingGet.mockReturnValue('three')
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
new ArrayBuffer(0)
)
})
it('returns a Group containing a Mesh for non-pointCloud modes', async () => {
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('original')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(child).toBeInstanceOf(THREE.Mesh)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
})
it('returns a Group containing Points when materialMode is pointCloud', async () => {
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('pointCloud')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(child).toBeInstanceOf(THREE.Points)
})
})
})

View File

@@ -0,0 +1,120 @@
import * as THREE from 'three'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import { FastPLYLoader } from './loader/FastPLYLoader'
export function getPLYEngine(): string {
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
}
export class PointCloudModelAdapter implements ModelAdapter {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
exportable: true,
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
fitTargetSize: 5
}
private readonly plyLoader = new PLYLoader()
private readonly fastPlyLoader = new FastPLYLoader()
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const arrayBuffer = await fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
const plyGeometry =
isASCII && getPLYEngine() === 'fastply'
? this.fastPlyLoader.parse(arrayBuffer)
: this.plyLoader.parse(arrayBuffer)
ctx.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
if (ctx.materialMode === 'pointCloud') {
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
}
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
}
}
function buildPointsGroup(
ctx: ModelLoadContext,
geometry: THREE.BufferGeometry,
hasVertexColors: boolean
): THREE.Group {
geometry.computeBoundingSphere()
if (geometry.boundingSphere) {
const { center, radius } = geometry.boundingSphere
geometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
geometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const points = new THREE.Points(geometry, pointMaterial)
ctx.registerOriginalMaterial(points as unknown as THREE.Mesh, pointMaterial)
const group = new THREE.Group()
group.add(points)
return group
}
function buildMeshGroup(
ctx: ModelLoadContext,
geometry: THREE.BufferGeometry,
hasVertexColors: boolean
): THREE.Group {
const material = hasVertexColors
? new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
: ctx.standardMaterial.clone()
if (!hasVertexColors && material instanceof THREE.MeshStandardMaterial) {
material.side = THREE.DoubleSide
}
const mesh = new THREE.Mesh(geometry, material)
ctx.registerOriginalMaterial(mesh, material)
const group = new THREE.Group()
group.add(mesh)
return group
}

View File

@@ -0,0 +1,82 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelLoadContext } from './ModelAdapter'
import * as ModelAdapterModule from './ModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
const { splatMeshCtor } = vi.hoisted(() => ({
splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>()
}))
vi.mock('@sparkjsdev/spark', async () => {
const three = await import('three')
return {
SplatMesh: class extends three.Object3D {
constructor(opts: { fileBytes: ArrayBuffer }) {
super()
splatMeshCtor(opts)
}
}
}
})
function makeContext(): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode: 'original'
}
}
describe('SplatModelAdapter', () => {
beforeEach(() => {
splatMeshCtor.mockReset()
})
it('exposes splat capabilities on the adapter', () => {
const adapter = new SplatModelAdapter()
expect(adapter.kind).toBe('splat')
expect(adapter.capabilities.lighting).toBe(false)
expect(adapter.capabilities.exportable).toBe(false)
expect([...adapter.capabilities.materialModes]).toEqual([])
})
it('handles the Gaussian splat extensions', () => {
const adapter = new SplatModelAdapter()
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
})
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
const buf = new ArrayBuffer(128)
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(buf)
const adapter = new SplatModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view?', 'scene.splat')
expect(ModelAdapterModule.fetchModelData).toHaveBeenCalledWith(
'/api/view?',
'scene.splat'
)
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
expect(result).toBeInstanceOf(THREE.Group)
expect(result.children).toHaveLength(1)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
})
it('propagates fetch errors', async () => {
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
new Error('Failed to fetch model: 500')
)
const adapter = new SplatModelAdapter()
await expect(
adapter.load(makeContext(), '/api/view?', 'scene.splat')
).rejects.toThrow('Failed to fetch model: 500')
})
})

View File

@@ -0,0 +1,38 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
export class SplatModelAdapter implements ModelAdapter {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
requiresMaterialRebuild: false,
gizmoTransform: false,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 5
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
ctx.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
}

View File

@@ -3,11 +3,7 @@
import type * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import type { OBJLoader2Parallel } from 'wwobjloader2'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
export type MaterialMode =
| 'original'
@@ -203,12 +199,6 @@ export interface ModelManagerInterface {
}
export interface LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
init(): void
dispose(): void
loadModel(url: string, originalFileName?: string): Promise<void>

View File

@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
function rightMouse(type: string, x: number, y: number, buttons = 2) {
const event = new MouseEvent(type, {
button: 2,
buttons,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true
})
return event
}
describe('attachContextMenuGuard', () => {
let target: HTMLElement
let onMenu: ReturnType<typeof vi.fn<(event: MouseEvent) => void>>
let dispose: () => void
beforeEach(() => {
target = document.createElement('div')
document.body.appendChild(target)
onMenu = vi.fn<(event: MouseEvent) => void>()
})
afterEach(() => {
dispose?.()
target.remove()
})
it('invokes onMenu for a right-click without drag movement', () => {
dispose = attachContextMenuGuard(target, onMenu)
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
expect(onMenu).toHaveBeenCalledOnce()
})
it('preventDefault is called on the contextmenu event when menu fires', () => {
dispose = attachContextMenuGuard(target, onMenu)
target.dispatchEvent(rightMouse('mousedown', 0, 0))
const contextEvent = rightMouse('contextmenu', 0, 0)
target.dispatchEvent(contextEvent)
expect(contextEvent.defaultPrevented).toBe(true)
})
it('suppresses onMenu when the mouse moved past the drag threshold', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 120, 120))
target.dispatchEvent(rightMouse('contextmenu', 120, 120))
expect(onMenu).not.toHaveBeenCalled()
})
it('still fires onMenu when the mouse moved within the drag threshold', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 10 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 103, 104))
target.dispatchEvent(rightMouse('contextmenu', 103, 104))
expect(onMenu).toHaveBeenCalledOnce()
})
it('detects a drag from start to contextmenu even without mousemove events', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
expect(onMenu).not.toHaveBeenCalled()
})
it('resets drag state between right-clicks', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 200, 200))
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
expect(onMenu).not.toHaveBeenCalled()
target.dispatchEvent(rightMouse('mousedown', 50, 50))
target.dispatchEvent(rightMouse('contextmenu', 50, 50))
expect(onMenu).toHaveBeenCalledOnce()
})
it('ignores onMenu when isDisabled returns true', () => {
let disabled = true
dispose = attachContextMenuGuard(target, onMenu, {
isDisabled: () => disabled
})
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).not.toHaveBeenCalled()
disabled = false
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).toHaveBeenCalledOnce()
})
it('stops listening after dispose', () => {
dispose = attachContextMenuGuard(target, onMenu)
dispose()
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).not.toHaveBeenCalled()
})
it('ignores mousemove events without the right button held', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 200, 200, 0))
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
expect(onMenu).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,72 @@
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
type ContextMenuGuardOptions = {
isDisabled?: () => boolean
dragThreshold?: number
}
export function attachContextMenuGuard(
target: HTMLElement,
onMenu: (event: MouseEvent) => void,
{ isDisabled = () => false, dragThreshold = 5 }: ContextMenuGuardOptions = {}
): () => void {
const abort = new AbortController()
const { signal } = abort
let start = { x: 0, y: 0 }
let moved = false
target.addEventListener(
'mousedown',
(e) => {
if (e.button === 2) {
start = { x: e.clientX, y: e.clientY }
moved = false
}
},
{ signal }
)
target.addEventListener(
'mousemove',
(e) => {
if (
e.buttons === 2 &&
exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
dragThreshold
)
) {
moved = true
}
},
{ signal }
)
target.addEventListener(
'contextmenu',
(e) => {
if (isDisabled()) return
const wasDragging =
moved ||
exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
dragThreshold
)
moved = false
if (wasDragging) return
e.preventDefault()
e.stopPropagation()
onMenu(e)
},
{ signal }
)
return () => abort.abort()
}

View File

@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startRenderLoop } from './load3dRenderLoop'
describe('startRenderLoop', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs tick on each frame while isActive returns true', () => {
const tick = vi.fn()
const handle = startRenderLoop({ tick, isActive: () => true })
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
expect(tick.mock.calls.length).toBeGreaterThanOrEqual(3)
handle.stop()
})
it('skips tick on frames where isActive returns false', () => {
let active = false
const tick = vi.fn()
const handle = startRenderLoop({ tick, isActive: () => active })
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
expect(tick).not.toHaveBeenCalled()
active = true
vi.advanceTimersToNextTimer()
expect(tick).toHaveBeenCalledOnce()
handle.stop()
})
it('stop halts further ticks', () => {
const tick = vi.fn()
const handle = startRenderLoop({ tick, isActive: () => true })
vi.advanceTimersToNextTimer()
const callsBeforeStop = tick.mock.calls.length
handle.stop()
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextTimer()
expect(tick.mock.calls.length).toBe(callsBeforeStop)
})
it('is safe to call stop multiple times', () => {
const handle = startRenderLoop({ tick: vi.fn(), isActive: () => true })
handle.stop()
expect(() => handle.stop()).not.toThrow()
})
})

View File

@@ -0,0 +1,32 @@
type RenderLoopOptions = {
tick: () => void
isActive: () => boolean
}
export type RenderLoopHandle = {
stop: () => void
}
export function startRenderLoop({
tick,
isActive
}: RenderLoopOptions): RenderLoopHandle {
let frameId: number | null = null
const loop = () => {
frameId = requestAnimationFrame(loop)
if (!isActive()) return
tick()
}
loop()
return {
stop() {
if (frameId !== null) {
cancelAnimationFrame(frameId)
frameId = null
}
}
}
}

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
import type { Load3dActivityFlags } from './load3dViewport'
describe('computeLetterboxedViewport', () => {
it('pillarboxes when the container is wider than the target aspect', () => {
const viewport = computeLetterboxedViewport({ width: 800, height: 400 }, 1)
expect(viewport).toEqual({
offsetX: 200,
offsetY: 0,
width: 400,
height: 400
})
})
it('letterboxes when the container is taller than the target aspect', () => {
const viewport = computeLetterboxedViewport({ width: 400, height: 800 }, 1)
expect(viewport).toEqual({
offsetX: 0,
offsetY: 200,
width: 400,
height: 400
})
})
it('fills the container when aspect ratios match exactly', () => {
const viewport = computeLetterboxedViewport(
{ width: 1024, height: 768 },
1024 / 768
)
expect(viewport.offsetX).toBe(0)
expect(viewport.offsetY).toBe(0)
expect(viewport.width).toBe(1024)
expect(viewport.height).toBe(768)
})
it('handles a wide target aspect inside a square container', () => {
const viewport = computeLetterboxedViewport(
{ width: 600, height: 600 },
16 / 9
)
expect(viewport.offsetX).toBe(0)
expect(viewport.width).toBe(600)
expect(viewport.height).toBeCloseTo(337.5)
expect(viewport.offsetY).toBeCloseTo((600 - 337.5) / 2)
})
it('handles a tall target aspect inside a square container', () => {
const viewport = computeLetterboxedViewport(
{ width: 600, height: 600 },
9 / 16
)
expect(viewport.offsetY).toBe(0)
expect(viewport.height).toBe(600)
expect(viewport.width).toBeCloseTo(337.5)
expect(viewport.offsetX).toBeCloseTo((600 - 337.5) / 2)
})
it('preserves the target aspect ratio in the returned rect', () => {
const target = 16 / 9
const wide = computeLetterboxedViewport(
{ width: 1920, height: 500 },
target
)
const tall = computeLetterboxedViewport(
{ width: 500, height: 1920 },
target
)
expect(wide.width / wide.height).toBeCloseTo(target)
expect(tall.width / tall.height).toBeCloseTo(target)
})
})
describe('isLoad3dActive', () => {
const idle: Load3dActivityFlags = {
mouseOnNode: false,
mouseOnScene: false,
mouseOnViewer: false,
recording: false,
initialRenderDone: true,
animationPlaying: false
}
it('is inactive once the first frame is rendered with nothing happening', () => {
expect(isLoad3dActive(idle)).toBe(false)
})
it('is active before the first frame renders', () => {
expect(isLoad3dActive({ ...idle, initialRenderDone: false })).toBe(true)
})
it.each([
['mouseOnNode'],
['mouseOnScene'],
['mouseOnViewer'],
['recording'],
['animationPlaying']
] as const)('is active when %s is true', (flag) => {
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
})
})

View File

@@ -0,0 +1,55 @@
type Size = { width: number; height: number }
type LetterboxedViewport = {
offsetX: number
offsetY: number
width: number
height: number
}
export function computeLetterboxedViewport(
container: Size,
targetAspectRatio: number
): LetterboxedViewport {
const containerAspectRatio = container.width / container.height
if (containerAspectRatio > targetAspectRatio) {
const height = container.height
const width = height * targetAspectRatio
return {
offsetX: (container.width - width) / 2,
offsetY: 0,
width,
height
}
}
const width = container.width
const height = width / targetAspectRatio
return {
offsetX: 0,
offsetY: (container.height - height) / 2,
width,
height
}
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean
mouseOnViewer: boolean
recording: boolean
initialRenderDone: boolean
animationPlaying: boolean
}
export function isLoad3dActive(flags: Load3dActivityFlags): boolean {
return (
flags.mouseOnNode ||
flags.mouseOnScene ||
flags.mouseOnViewer ||
flags.recording ||
!flags.initialRenderDone ||
flags.animationPlaying
)
}

View File

@@ -579,7 +579,9 @@ export class LGraph
for (let i = 0; i < num; i++) {
for (let j = 0; j < limit; ++j) {
const node = nodes[j]
// FIXME: Looks like copy/paste broken logic - checks for "on", executes "do"
if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) {
// wrap node.onExecute();
node.doExecute?.()
}
}
@@ -595,8 +597,8 @@ export class LGraph
for (let i = 0; i < num; i++) {
for (let j = 0; j < limit; ++j) {
const node = nodes[j]
if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) {
node.doExecute?.()
if (node.mode == LGraphEventMode.ALWAYS) {
node.onExecute?.()
}
}

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "التطبيق",
"blueprint": "المخطط",
"clearWorkflow": "مسح سير العمل",
"deleteBlueprint": "حذف المخطط",
"deleteWorkflow": "حذف سير العمل",
@@ -918,6 +919,10 @@
"showSwapNodes": "عرض العقد البديلة",
"swapNodes": "يمكن استبدال بعض العقد ببدائل"
},
"errorPanelSurvey": {
"ctaButton": "أعطِ ملاحظاتك",
"ctaText": "ما رأيك في لوحة الأخطاء الجديدة؟"
},
"essentials": {
"batchImage": "معالجة صور دفعة واحدة",
"canny": "كانّي",

View File

@@ -2734,6 +2734,7 @@
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
"blueprint": "Blueprint",
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",

View File

@@ -11743,8 +11743,8 @@
}
},
"OpenAIVideoSora2": {
"display_name": "OpenAI Sora - Video",
"description": "OpenAI video and audio generation.",
"display_name": "OpenAI Sora - Video (Deprecated)",
"description": "OpenAI video and audio generation.\n\nDEPRECATION NOTICE: OpenAI will stop serving the Sora v2 API in September 2026. This node will be removed from ComfyUI at that time.",
"inputs": {
"model": {
"name": "model"

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "Aplicación",
"blueprint": "Plano",
"clearWorkflow": "Limpiar flujo de trabajo",
"deleteBlueprint": "Eliminar Plano",
"deleteWorkflow": "Eliminar flujo de trabajo",
@@ -918,6 +919,10 @@
"showSwapNodes": "Mostrar nodos intercambiables",
"swapNodes": "Algunos nodos pueden ser reemplazados por alternativas"
},
"errorPanelSurvey": {
"ctaButton": "Dar opinión",
"ctaText": "¿Qué te parece el nuevo panel de errores?"
},
"essentials": {
"batchImage": "Procesar imágenes por lotes",
"canny": "Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "برنامه",
"blueprint": "نقشه راه",
"clearWorkflow": "پاک‌سازی workflow",
"deleteBlueprint": "حذف blueprint",
"deleteWorkflow": "حذف workflow",
@@ -918,6 +919,10 @@
"showSwapNodes": "نمایش نودهای قابل جایگزینی",
"swapNodes": "برخی از نودها را می‌توان با گزینه‌های جایگزین تعویض کرد"
},
"errorPanelSurvey": {
"ctaButton": "ارسال بازخورد",
"ctaText": "نظر شما درباره پنل خطا جدید چیست؟"
},
"essentials": {
"batchImage": "پردازش دسته‌ای تصویر",
"canny": "لبه‌یابی Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "Application",
"blueprint": "Plan",
"clearWorkflow": "Effacer le workflow",
"deleteBlueprint": "Supprimer le plan",
"deleteWorkflow": "Supprimer le workflow",
@@ -918,6 +919,10 @@
"showSwapNodes": "Afficher les nœuds de remplacement",
"swapNodes": "Certains nœuds peuvent être remplacés par des alternatives"
},
"errorPanelSurvey": {
"ctaButton": "Donner votre avis",
"ctaText": "Que pensez-vous du nouveau panneau derreurs ?"
},
"essentials": {
"batchImage": "Traitement par lot d'images",
"canny": "Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "アプリ",
"blueprint": "ブループリント",
"clearWorkflow": "ワークフローをクリア",
"deleteBlueprint": "ブループリントを削除",
"deleteWorkflow": "ワークフローを削除",
@@ -918,6 +919,10 @@
"showSwapNodes": "代替可能なノードを表示",
"swapNodes": "いくつかのノードは代替可能です"
},
"errorPanelSurvey": {
"ctaButton": "フィードバックを送る",
"ctaText": "新しいエラーパネルはいかがですか?"
},
"essentials": {
"batchImage": "バッチ画像処理",
"canny": "Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "앱",
"blueprint": "블루프린트",
"clearWorkflow": "워크플로 내용 지우기",
"deleteBlueprint": "블루프린트 삭제",
"deleteWorkflow": "워크플로 삭제",
@@ -918,6 +919,10 @@
"showSwapNodes": "교체 가능한 노드 표시",
"swapNodes": "일부 노드는 대체 가능한 노드로 교체할 수 있습니다"
},
"errorPanelSurvey": {
"ctaButton": "피드백 남기기",
"ctaText": "새로운 오류 패널은 어떠신가요?"
},
"essentials": {
"batchImage": "이미지 일괄 처리",
"canny": "Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "App",
"blueprint": "Blueprint",
"clearWorkflow": "Limpar Fluxo de Trabalho",
"deleteBlueprint": "Excluir Blueprint",
"deleteWorkflow": "Excluir Fluxo de Trabalho",
@@ -918,6 +919,10 @@
"showSwapNodes": "Mostrar nós alternativos",
"swapNodes": "Alguns nós podem ser substituídos por alternativas"
},
"errorPanelSurvey": {
"ctaButton": "Enviar feedback",
"ctaText": "O que achou do novo painel de erros?"
},
"essentials": {
"batchImage": "Imagem em lote",
"canny": "Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "Приложение",
"blueprint": "Чертёж",
"clearWorkflow": "Очистить рабочий процесс",
"deleteBlueprint": "Удалить схему",
"deleteWorkflow": "Удалить рабочий процесс",
@@ -918,6 +919,10 @@
"showSwapNodes": "Показать заменяемые узлы",
"swapNodes": "Некоторые узлы можно заменить альтернативами"
},
"errorPanelSurvey": {
"ctaButton": "Оставить отзыв",
"ctaText": "Как вам новая панель ошибок?"
},
"essentials": {
"batchImage": "Пакетная обработка изображений",
"canny": "Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "Uygulama",
"blueprint": "Plan",
"clearWorkflow": "İş Akışını Temizle",
"deleteBlueprint": "Taslağı Sil",
"deleteWorkflow": "İş Akışını Sil",
@@ -918,6 +919,10 @@
"showSwapNodes": "Değiştirilebilir düğümleri göster",
"swapNodes": "Bazı düğümler alternatiflerle değiştirilebilir"
},
"errorPanelSurvey": {
"ctaButton": "Geri bildirim ver",
"ctaText": "Yeni hata paneli nasıl?"
},
"essentials": {
"batchImage": "Toplu Görüntü",
"canny": "Canny",

View File

@@ -335,6 +335,7 @@
},
"breadcrumbsMenu": {
"app": "應用程式",
"blueprint": "藍圖",
"clearWorkflow": "清除工作流程",
"deleteBlueprint": "刪除藍圖",
"deleteWorkflow": "刪除工作流程",
@@ -918,6 +919,10 @@
"showSwapNodes": "顯示可替換的節點",
"swapNodes": "有些節點可以用其他選項替換"
},
"errorPanelSurvey": {
"ctaButton": "提供回饋",
"ctaText": "新的錯誤面板感覺如何?"
},
"essentials": {
"batchImage": "批次圖片",
"canny": "Canny 邊緣",

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