Compare commits

..

48 Commits

Author SHA1 Message Date
Comfy Org PR Bot
389ff8ba49 1.44.13 (#11732)
Patch version increment to 1.44.13

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11732-1-44-13-3516d73d36508119acabf5f86256f2aa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-29 01:23:53 +00:00
Terry Jia
bb74ec94de test(load3d): add unit tests for 9 previously-untested controls (#11730)
## Summary
Mirror the maskeditor coverage approach for load3d sub-components. Each
component gets behavior tests covering rendering, conditional branches,
v-model bidirectional sync, and emitted events.

- ViewerLightControls: setting-store min/max/step + v-model
- ViewerExportControls: format dropdown + click-to-export
- ViewerCameraControls: type select, FOV slider visibility
- ViewerSceneControls: with/without bg image branches, render mode
- PopupSlider: trigger toggle, click-outside dismissal, defaults
- CameraControls: switch button, FOV PopupSlider visibility
- ExportControls: trigger popup, format selection, click-outside
- AnimationControls: empty-list bypass, controls, time formatting
- ViewerControls: dialog open routing + onClose wiring

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11730-test-load3d-add-unit-tests-for-9-previously-untested-controls-3506d73d365081eaa9e7c5d0b922fc14)
by [Unito](https://www.unito.io)
2026-04-28 19:23:09 -04:00
Kelly Yang
e7640d414b test: add E2E tests for ActionBarButtons toolbar component (#11561)
## Summary

- Add E2E tests for the `ActionBarButtons` toolbar component (FE-111)
- Add `data-testid="action-bar-buttons"` to the container div for stable
test targeting
- Register `TestIds.topbar.actionBarButtons` in `selectors.ts`

## Changes

- `browser_tests/tests/actionBarButtons.spec.ts` — 6 tests across 5
scenarios: empty state, button rendering, icon rendering, multiple
buttons, click handler, mobile label hiding
- `src/components/topbar/ActionBarButtons.vue` — adds `data-testid` to
container
- `browser_tests/fixtures/selectors.ts` — registers new test ID

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily adds Playwright coverage and a `data-testid` attribute;
runtime behavior is unchanged aside from an extra DOM attribute.
> 
> **Overview**
> Adds a new Playwright spec (`actionBarButtons.spec.ts`) that verifies
the ActionBarButtons container empty state, rendering (label/icon),
multiple buttons, click handler execution, and mobile label-hiding
behavior by registering buttons via `window.app!.registerExtension`.
> 
> Updates the UI and test selector plumbing by adding
`data-testid="action-bar-buttons"` to `ActionBarButtons.vue` and
exposing it as `TestIds.topbar.actionBarButtons` for stable E2E
targeting.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
80f90d1f1d. 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-11561-test-add-E2E-tests-for-ActionBarButtons-toolbar-component-34b6d73d36508153874fda856a78817f)
by [Unito](https://www.unito.io)
2026-04-28 19:13:12 -04:00
Alexander Brown
c168c37c94 chore: update comfyui-ci-container to 0.0.17 (#11569)
Update `comfyui-ci-container` image to `0.0.17` across all CI workflows.

| Workflow | Before | After |
|---|---|---|
| `ci-perf-report.yaml` | `0.0.12` | `0.0.17` |
| `ci-tests-e2e.yaml` (×2) | `0.0.16` | `0.0.17` |
| `pr-update-playwright-expectations.yaml` | `0.0.16` | `0.0.17` |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11569-chore-update-comfyui-ci-container-to-0-0-17-34b6d73d365081b8a52ac995855354cb)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 22:05:28 +00:00
pythongosssss
089051824c test: add tests for link related settings (#11612)
## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11612-test-add-tests-for-link-related-settings-34c6d73d36508145885bd017162e6fae)
by [Unito](https://www.unito.io)
2026-04-28 22:02:42 +00:00
pythongosssss
517da289f6 feat: Search - add ghost node following setting and increase opacity (#11365)
## Summary

Adds setting to disable the node auto-follow cursor behavior when adding
nodes from the search, and increased the visibilty of Vue ghost nodes.

## Changes

- **What**: 
- add setting
- increase opacity
- add test

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

Before  
<img width="452" height="517" alt="image"
src="https://github.com/user-attachments/assets/369c0d90-5352-482b-a1b3-36180bffb3ee"
/>

After  
<img width="440" height="536" alt="image"
src="https://github.com/user-attachments/assets/2066fdd4-6eb4-4bfb-ac7c-559fc99de57d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11365-feat-Search-add-ghost-node-following-setting-and-increase-opacity-3466d73d3650811b9c27ed4cc930816d)
by [Unito](https://www.unito.io)
2026-04-28 22:02:33 +00:00
Dante
98c327b3c6 test: add unit tests for colorUtil edge cases (#11671)
## Summary

Extends `colorUtil.test.ts` with boundary tests that the existing suite
did not cover: malformed `parseToRgb` inputs, alpha-hex parsing through
`parseToRgb`, negative hue normalization in `hsbToRgb`, the full
`isTransparent` matrix, HSV-vs-HSB equivalence in `toHexFromFormat`, the
bare-hex prefix path, and a non-primary-color round-trip through
`hexToHsva` / `hsvaToHex`.

## Changes

- **What**: Adds 13 Vitest cases across 5 new `describe` blocks
(`parseToRgb edge cases`, `hsbToRgb normalization`, `isTransparent`,
`toHexFromFormat`, plus a non-primary round-trip in the existing
`hexToHsva / hsvaToHex` block). Uses the existing
`vi.mock('es-toolkit/compat')` memoize stub.

## Review Focus

- The non-primary palette round-trip allows ±1 per RGB channel because
`hsbToRgb` floors while `rgbToHex` rounds; the test asserts the bound
rather than exact equality.
- `parseToRgb` is exercised with alpha-bearing hex (`#f008`,
`#ff000080`); the function returns RGB-only, so the alpha is
intentionally discarded.
- `toHexFromFormat({h, s, v}, 'hsb')` covers the HSV-shaped object path
that wraps `hsbToRgb`.

## Testing

\`\`\`bash
pnpm exec vitest run src/utils/colorUtil.test.ts
pnpm format -- src/utils/colorUtil.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

73 tests pass (60 prior + 13 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11671-test-add-unit-tests-for-colorUtil-edge-cases-34f6d73d36508136bac9edfae32815ec)
by [Unito](https://www.unito.io)
2026-04-28 16:50:20 -04:00
Terry Jia
fc2a4e82cf feat(load3d): bind UI capability gating to ModelAdapterCapabilities (#11711)
> Final piece of the PLY / 3D Gaussian Splatting series. Previous PR
made `ModelAdapterCapabilities` load-bearing on the engine side; the UI
was still gating off `isSplatModel` / `isPlyModel` proxies. This PR
routes the viewer and the viewer-mode dialog through the capability
fields directly, so the same source of truth that drives `Load3d`
behavior also drives what the user sees. Eighth and last in the series
splitting up.

## Summary

Snapshot `Load3d.getCurrentModelCapabilities()` into 5 Vue refs on each
model load and pipe them through the existing `Load3D` /
`Load3DControls` / `Load3dViewerContent` / `ModelControls` /
`ViewerModelControls` / `Preview3d` props. Replaces the format-specific
`:is-splat-model` / `:is-ply-model` props and the hardcoded "splat →
drop light/gizmo/export" subtraction with additive capability gates. No
engine behavior changes — capability values are what previous PR already
produces; UI now consumes them.

## Changes

- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: 5 new refs
(`canFitToViewer` / `canUseGizmo` / `canUseLighting` / `canExport` /
`materialModes`) refreshed on every load via
`load3d.getCurrentModelCapabilities()`. `useLoad3dViewer` extracts the
snapshot into a single `captureAdapterFlags(source)` helper because it
runs in three places (initializeViewer / initializeStandaloneViewer /
loadStandaloneModel).
- **`Load3D.vue`**: gate the fit-to-viewer button on `canFitToViewer`;
pass capability refs to `Load3DControls` instead of `isSplatModel` /
`isPlyModel`.
- **`Load3DControls.vue`**: build `availableCategories` additively
(`['scene','model','camera']` plus `light` / `gizmo` / `export` if their
capability is true) rather than subtracting from a fixed list when
`isSplatModel` is true. Forwards `materialModes` to `ModelControls`.
- **`Load3dViewerContent.vue`**: gate the light / gizmo / export sidebar
sections on the capability refs; pass `materialModes` to
`ViewerModelControls`.
- **`ModelControls.vue` / `ViewerModelControls.vue`**: drop the local
`materialModes` computed (which derived its options from `isPlyModel`
and a hardcoded mesh list) and accept `materialModes` as a `readonly
MaterialMode[]` prop. An empty array hides the dropdown entirely.
- **`Preview3d.vue`** (renderer linearMode): mirror the prop swap on the
standalone preview path.

## Review Focus

- **Capability prop wiring is the only public-API change for child
components**. `ModelControls` and `ViewerModelControls` lost
`hideMaterialMode` / `isPlyModel` props. Any extension that imported
these components directly will need to migrate, but they're internal
`src/components/load3d/controls/**` files and not part of the documented
extension surface.
- **Empty-`materialModes` semantics**: previously hidden via
`:hide-material-mode`; now hidden via `materialModes.length === 0`.
`SplatModelAdapter` declares `materialModes: []`, so the splat case
keeps the same behavior — the dropdown disappears. PLY adds
`'pointCloud'` to the array, so the dropdown picks up that mode
automatically without the controls needing an `isPlyModel` branch.
- **`captureAdapterFlags` runs after every load completes**, so
switching between mesh and splat in the same viewer instance updates the
chrome correctly. Verified via the new `Load3D.test.ts` /
`Load3dViewerContent.test.ts` cases.
- **Capability gating is inclusive of `canFitToViewer`** in this PR even
though `Load3DControls` has no fit category — the fit-to-viewer floating
button on `Load3D.vue` is what reads it. PLY's `fitToViewer: true` means
the button stays visible for PLY users.

## Coverage

| File | Stmts | Branch | Funcs |
|---|---|---|---|
| `Load3D.vue` (modified) | 53.3% | **95.5%** | 83.3% |
| `Load3DControls.vue` (modified) | 77.5% | **94.8%** | 86.4% |
| `Load3dViewerContent.vue` (modified) | 60.6% | 72.1% | 54.5% |
| `controls/ModelControls.vue` (modified) | 16.3% | 0% | 0% |
| `controls/viewer/ViewerModelControls.vue` (modified) | **100%** |
**100%** | **100%** |
| `composables/useLoad3d.ts` (modified) | 78.7% | 64.5% | 71.4% |
| `composables/useLoad3dViewer.ts` (modified) | 76.0% | 52.1% | 66.7% |

Four new test files (`Load3D.test.ts` / `Load3DControls.test.ts` /
`Load3dViewerContent.test.ts` /
`controls/viewer/ViewerModelControls.test.ts`) cover the new capability
gating directly: each component is rendered with capability flags
toggled on/off and the appropriate sidebar / dropdown / button
visibility is asserted. Capability prop forwarding from `Load3D.vue` →
`Load3DControls.vue` and from `Load3dViewerContent.vue` →
`ViewerModelControls.vue` is exercised end-to-end.

`controls/ModelControls.vue` is the legacy node-side ModelControls — its
existing tests live elsewhere and were not in this PR's scope; the diff
line covered (the `v-if="materialModes.length > 0"` swap) is exercised
by the new `Load3DControls.test.ts` cases that drive a non-empty / empty
`materialModes` through. `Preview3d.vue` (renderer linearMode) has no
test file in the project; the prop swap there is the same shape as the
`Load3D.vue` swap which is covered.

`useLoad3d.ts` / `useLoad3dViewer.ts` percentages are roughly the
pre-existing baseline. The diff lines (the 5 new refs and the
`captureAdapterFlags` helper) are exercised by the existing composable
tests via the mock that now stubs `getCurrentModelCapabilities()`.

73 new component unit tests; 393 total load3d-related tests pass on this
branch.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11711-feat-load3d-bind-UI-capability-gating-to-ModelAdapterCapabilities-3506d73d365081b3af68f30e3f728e24)
by [Unito](https://www.unito.io)
2026-04-28 16:39:06 -04:00
pythongosssss
e48d33e4c0 test: Canvas grid, ctx menu scaling and group padding settings (#11721)
## Summary

Adds tests for canvas snap to grid, context menu scaling and group
padding

## Changes

- **What**: 
- add tests for canvas related settings

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11721-test-Canvas-grid-ctx-menu-scaling-and-group-padding-settings-3506d73d36508141868cfa990d903c33)
by [Unito](https://www.unito.io)
2026-04-28 17:21:55 +00:00
pythongosssss
967f1eb562 test: extract title editor test component (#11605)
## Summary

Extract shared TitleEditor component and update tests to use it

## Changes

- **What**: 
- add title editor helper
- update locations that used `TestIds.node.titleInput`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11605-test-extract-title-editor-test-component-34c6d73d3650811da6b0ec493b190c3f)
by [Unito](https://www.unito.io)
2026-04-28 09:46:25 -04:00
Kelly Yang
8b83559402 refactor: extract useBrushPersistence from useBrushDrawing (#11543)
## Summary

PR B of this https://github.com/Comfy-Org/ComfyUI_frontend/pull/11388
Part of the `useBrushDrawing` decomposition plan (PR B). 
Extracts brush settings persistence logic into a dedicated
`useBrushPersistence` composable, reducing the responsibility surface of
`useBrushDrawing`.

No runtime behavior is changed — this is a pure structural refactor.

## Changes

- **New** `src/composables/maskeditor/useBrushPersistence.ts` —
encapsulates `loadAndApply` (reads brush settings from localStorage and
applies them to the store on init) and `save` (debounced write of
current brush settings to localStorage)
- **New** `src/composables/maskeditor/useBrushPersistence.test.ts` —
unit tests covering load from empty storage, full restore round-trip,
missing `stepSize` fallback, corrupted data resilience, and
save-to-localStorage behavior
- **Updated** `src/composables/maskeditor/useBrushDrawing.ts` — removes
the inlined persistence functions and delegates to `useBrushPersistence`

## Test Locally
1. Adjust brush size and hardness, close MaskEditor, then reopen it —
brush parameters should restore to the previous settings (verifies
`save` + `loadAndApply`) - pass
2. Draw a few strokes and confirm the marks appear correctly (verifies
the `saveBrushSettings` public interface is not broken) - pass


https://github.com/user-attachments/assets/961155d5-6742-4668-a419-51c29b850edf





<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk refactor that moves localStorage read/write logic behind a
new composable and adds unit tests; main risk is accidental behavior
drift in when/what brush settings are persisted/restored.
> 
> **Overview**
> Refactors mask editor brush settings persistence by extracting the
localStorage load/save (including debounced writes and `stepSize`
fallback) out of `useBrushDrawing` into a new `useBrushPersistence`
composable, while keeping the `saveBrushSettings` public API wired
through.
> 
> Adds `useBrushPersistence` unit tests covering empty storage,
round-trip restore, missing field defaults, corrupted JSON handling, and
save semantics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d87d7c8bcf. 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-11543-refactor-extract-useBrushPersistence-from-useBrushDrawing-34a6d73d36508144b7edff759a1e3485)
by [Unito](https://www.unito.io)
2026-04-28 09:15:39 -04:00
Kelly Yang
bc11b5ff5e test: add E2E tests for LoginButton toolbar component (#11562)
## Summary

- Add E2E tests for the `LoginButton` toolbar component (FE-109)
- Add `data-testid="login-button"` to `LoginButton.vue` for stable
targeting
- Register `TestIds.topbar.loginButton` in `selectors.ts`

## Changes

- `browser_tests/tests/loginButton.spec.ts` — 7 tests covering:
visibility toggled by `show_signin_button` server feature flag, ARIA
label, click → sign-in dialog, hover popover content, popover dismissal
- `src/components/topbar/LoginButton.vue` — adds `data-testid`
- `browser_tests/fixtures/selectors.ts` — registers new test ID

## Notes

`LoginButton` is rendered in `WorkflowTabs` when `flags.showSignInButton
?? isDesktop` is true. Since `isDesktop = false` in the OSS test build
(`DISTRIBUTION=localhost`), tests enable the button by setting
`window.app.api.serverFeatureFlags.value.show_signin_button = true` —
the established pattern used throughout the test suite (e.g.
`nodeLibraryEssentials.spec.ts`, `shareWorkflowDialog.spec.ts`).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to adding stable `data-testid` hooks
plus new Playwright E2E coverage, with only a minor adjustment to a
unit-test performance threshold.
> 
> **Overview**
> Adds Playwright E2E coverage for the topbar `LoginButton`, including
feature-flag-driven visibility, ARIA labeling, click-to-open sign-in
dialog, and hover popover behavior (including the *Learn more* link and
dismissal).
> 
> To make the tests stable, `LoginButton.vue` now exposes `data-testid`
attributes for the button and popover elements, and `selectors.ts`
registers new `TestIds.topbar.*` entries.
> 
> Relaxes the `useModelToNodeStore.getCategoryForNodeType` performance
test threshold (from 10ms to 100ms) while clarifying the intent of the
check.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1d4dd0bdca. 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-11562-test-add-E2E-tests-for-LoginButton-toolbar-component-34b6d73d365081f1bf2edc747d69ee52)
by [Unito](https://www.unito.io)
2026-04-28 08:33:42 -04:00
Dante
8c1ea7ae64 test: add unit tests for useNodePricing edge cases (#11673)
## Summary

Extends `useNodePricing.test.ts` with three behavioral gaps the existing
suite did not cover: `getNodePricingConfig` does not leak the compiled
JSONata expression, `pricingRevision` ticks after async evaluation
resolves (with a cache-hit path that does not), and
`formatPricingResult` returns `''` for non-finite numeric inputs across
all four result types.

## Changes

- **What**: Adds 9 Vitest cases across two existing `describe` blocks
(`getNodePricingConfig`, `formatPricingResult`) and one new block
(`reactive revision`). Reuses the existing `priceBadge` and
`createMockNodeWithPriceBadge` helpers.

## Review Focus

- The cache-hit assertion checks that `pricingRevision.value` does not
advance after a second `getNodeDisplayPrice` call with the same
signature, exercising the WeakMap cache hit at `useNodePricing.ts:573`.
- Non-finite coverage spans `type:'usd'`, `type:'range_usd'`,
`type:'list_usd'` (empty + all-non-finite + mixed), and the legacy `{
usd }` shape, matching the four `asFiniteNumber` call sites in
`formatPricingResult`.
- The strip-`_compiled` assertion uses `toHaveProperty` so the test
fails loudly if a future refactor accidentally re-exposes the runtime
JSONata instance to debug consumers.

## Testing

\`\`\`bash
pnpm exec vitest run src/composables/node/useNodePricing.test.ts
pnpm format -- src/composables/node/useNodePricing.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

91 tests pass (82 prior + 9 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11673-test-add-unit-tests-for-useNodePricing-edge-cases-34f6d73d365081dab525cdaa71b348a5)
by [Unito](https://www.unito.io)
2026-04-28 04:15:34 -07:00
Dante
69e68847d9 test: add unit tests for workspaceAuthStore retry/race paths (#11674)
## Summary

Extends `useWorkspaceAuth.test.ts` with the retry, race,
storage-resilience, and Zod-validation paths the existing suite did not
exercise: exponential backoff on `TOKEN_EXCHANGE_FAILED`, immediate
context clearing on permanent errors, stale-refresh abort when the user
switches workspaces mid-flight, and resilience to `sessionStorage` quota
errors.

## Changes

- **What**: Adds 6 Vitest cases across three new `describe` blocks
(`refreshToken retry/race paths`, `persistToSession resilience`, `Zod
validation on token response`). Reuses the existing `mockGetIdToken`,
`mockTeamWorkspacesEnabled`, `vi.useFakeTimers()`, and
`mockTokenResponse` fixtures.

## Review Focus

- The retry test uses `vi.runAllTimersAsync()` so the three backoff
sleeps (1s + 2s + 4s) drain in a single tick instead of slowing the
suite. Both `console.warn` (per-attempt) and `console.error`
(final-failure) are silenced.
- The race test resolves the in-flight refresh fetch with a token tied
to the OLD workspace AFTER `switchWorkspace('workspace-other')` has run,
so the assertion fails loudly if the stale-request guard regresses.
- The sessionStorage spy targets the instance method
(`vi.spyOn(sessionStorage, 'setItem')`); spying
`Storage.prototype.setItem` does not intercept happy-dom's per-instance
method.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/workspace/stores/useWorkspaceAuth.test.ts
pnpm format -- src/platform/workspace/stores/useWorkspaceAuth.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

33 tests pass (27 prior + 6 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11674-test-add-unit-tests-for-workspaceAuthStore-retry-race-paths-34f6d73d365081e6a8ecce59a156585e)
by [Unito](https://www.unito.io)
2026-04-28 02:56:38 -07:00
comfydesigner
fad9cf0db7 fix: consolidate --color-coral-red variables into --color-coral (#10374)
Removes the desktop-exclusive `--color-coral-red` CSS variables and
replaces their usage with the shared `--color-coral` palette to reduce
variable duplication in the design system.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10374-fix-consolidate-color-coral-red-variables-into-color-coral-32a6d73d365081a4ac88d0ea96aeea02)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alex <alex@Mac.lan>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-04-28 09:17:48 +00:00
pythongosssss
d532fcf779 test: add tests for canvas related settings (#11604)
## Summary

Adds test coverage for canvas related settings

## Changes

- **What**: 
- tests canvas info visibility, fps, pointer modes, selection

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11604-test-add-tests-for-canvas-related-settings-34c6d73d365081748b0ec79bc6fdc6ca)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-28 08:52:49 +00:00
pythongosssss
52e73f2697 test: Expand node search box V2 e2e coverage (#10620)
## Summary

Adds additional browser test coverage to the v2 node search

## Changes

- **What**:  
- extend search v2 fixtures
   - add additional tests
   - add data-testid for targeting elements
   - rework tests to work with new menu UI

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10620-test-Expand-node-search-box-V2-e2e-coverage-3306d73d365081b6bad3f73daab1194f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-28 08:52:28 +00:00
Simon Pinfold
c4043637d6 fix: route context menu Download through downloadMultipleAssets (#11700)
*PR Created by the Glary-Bot Agent*

---

## Summary

The asset context menu's Download action called `downloadAsset`
directly, so multi-output jobs only downloaded the preview file instead
of all outputs. Route through `downloadMultipleAssets`, which detects
multi-output jobs and creates a ZIP export in cloud mode and falls back
to the single-download path otherwise.

## Changes

- **What**: Swap `actions.downloadAsset(asset)` for
`actions.downloadMultipleAssets([asset])` in the per-asset context menu
Download command, and extend the existing unit test to assert the
routing.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

For single-output assets the behavior is unchanged:
`downloadMultipleAssets([asset])` falls through to the
individual-download path when `hasMultiOutputJobs` is false and
`assets.length === 1` (see `useMediaAssetActions.ts:106`). Verified
manually — right-clicking a single-output asset and clicking Download
still produces one file download to the correct `/api/view` URL.

## Notes

This is a focused replacement for the stale #10948. Compared to that
branch:
- Drops the unrelated `bootstrapStore` API-key auth changes (scope
creep).
- Drops the new `assets.cloud.spec.ts` Playwright spec — cloud
asset-export E2E coverage was added in #11610
(`browser_tests/tests/sidebar/assets.spec.ts`), so a separate cloud spec
for this routing change would mostly duplicate it.
- Keeps the unit-test change minimal: extends the existing `ContextMenu`
stub with a `model` prop watcher and adds one new test, rather than
rewriting the whole file from `@testing-library/vue` to
`@vue/test-utils`.

## Verification

- `pnpm test:unit` (MediaAssetContextMenu.test.ts and
useMediaAssetActions.test.ts)
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format` / `oxfmt --check`
- `pnpm knip`
- Manual: started the OSS dev server, generated a single-output asset
via the queue API, opened the assets sidebar, right-clicked the asset,
and confirmed the Download menu item triggers a single-file download
(screenshot attached).

## Screenshots

![Asset context menu open showing the Download item alongside Inspect,
Insert, Open/Export workflow, Copy job ID, and
Delete](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4727a22df87c291f5308dc348f591650709465b85acabe6b32a3982700450920/pr-images/1777331763941-b7877d53-7271-4a47-a18a-266842e193b6.png)

![Assets sidebar after clicking Download on a single-output asset;
context menu dismissed, no
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4727a22df87c291f5308dc348f591650709465b85acabe6b32a3982700450920/pr-images/1777331764293-4849e094-a8d2-4553-8cf7-2d050f3cc072.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11700-fix-route-context-menu-Download-through-downloadMultipleAssets-34f6d73d365081eb8135e8b699640d97)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-28 06:31:45 +00:00
Alexander Brown
9d61b4df06 feat: ECS Phase 0b — ID type aliases (FE-166/475/476/477) (#11699)
## Summary

ECS Phase 0b — type-only ID aliases. Builds on FE-165 (centralized
version counter, base of this PR) and adds a tranche of named ID aliases
plus mechanical adoption at known call sites.

This PR now covers four child tickets:

- **FE-166** — adds `GroupId` (in `LGraphGroup.ts`) and `SlotIndex` (in
`interfaces.ts`); re-exported from the litegraph barrel.
- **FE-475** — mechanical adoption of existing aliases (`NodeId`,
`LinkId`, `RerouteId`, `GroupId`, `SlotIndex`, `ExecutionId`) across
litegraph at the audit-listed sites:
`LGraphState.lastGroupId/lastLinkId/lastRerouteId`,
`LGraphExtra.linkExtensions`, `ISerialisedGroup.id`,
`ISerialisedGraph.last_link_id`, `LinkNetwork.removeReroute`,
`INodeOutputSlot.slot_index`, `LGraphNode.{setOutputDataType,
getInputDataType, getOutputPos}` slot params,
`ExecutableNodeDTO.inputs[].linkId` + execution-id locals, and
`RenderLink/MovingLinkBase.fromSlotIndex` (plus subclasses that
redeclare).
- **FE-476** — adds `SubgraphId = UUID` in `LGraph.ts`; adopted at
`_subgraphs` Map, `findUsedSubgraphIds`, `getDirectSubgraphIds`, and
`ExportedSubgraphInstance.type`. Re-exported from the litegraph barrel.
- **FE-477** — adds app-domain entity aliases at their closest
schema/types files: `WorkflowId`, `AssetId`, `PromptId` (propagated as
existing `JobId`), `TaskId`, `UserId`, `WorkspaceId`,
`WorkspaceInviteId`, `NodePackId`. Adopted at primary use sites (entity
id fields, store state, service signatures).

## Entity reference

### ID aliases at a glance

| Alias | Underlying | Defined in | Identifies |
| --- | --- | --- | --- |
| `NodeId` | `number \| string` | `litegraph/LGraphNode.ts` | A node
within a graph |
| `LinkId` | `number` | `litegraph/LLink.ts` | A connection between two
slots |
| `RerouteId` | `number` | `litegraph/Reroute.ts` | A reroute waypoint
on a link |
| `GroupId` *(new)* | `number` | `litegraph/LGraphGroup.ts` | A visual
group of nodes |
| `SlotIndex` *(new)* | `number` | `litegraph/interfaces.ts` | A slot's
position on a node |
| `ExecutionId` | `string` | `litegraph/types/serialisation.ts` | A node
within a subgraph instance |
| `SubgraphId` *(new)* | `UUID` | `litegraph/LGraph.ts` | A subgraph
definition |
| `WorkflowId` *(new)* | `string` |
`platform/workflow/validation/schemas/workflowSchema.ts` | A saved
workflow document |
| `AssetId` *(new)* | `string` |
`platform/assets/schemas/assetSchema.ts` | A binary asset (model, image,
etc.) |
| `JobId` *(reused as `PromptId`)* | `string` | `schemas/apiSchema.ts` |
A queued prompt execution |
| `TaskId` *(new)* | `string` | `platform/tasks/services/taskService.ts`
| A backend background task |
| `UserId` *(new)* | `string` | `types/authTypes.ts` | An authenticated
user |
| `WorkspaceId` *(new)* | `string` |
`platform/workspace/workspaceTypes.ts` | A workspace |
| `WorkspaceInviteId` *(new)* | `string` |
`platform/workspace/workspaceTypes.ts` | A pending workspace invite |
| `NodePackId` *(new)* | `string` |
`workbench/extensions/manager/types/comfyManagerTypes.ts` | A Comfy
Registry / Manager node pack |

### How the entities relate

```mermaid
flowchart TB
  subgraph LG["🎨 Litegraph entities"]
    direction TB
    Graph["LGraph<br/>id: SubgraphId"]
    Subgraph["Subgraph<br/>id: SubgraphId"]
    Node["LGraphNode<br/>id: NodeId"]
    Link["LLink<br/>id: LinkId"]
    Reroute["Reroute<br/>id: RerouteId"]
    Group["LGraphGroup<br/>id: GroupId"]
    Slot["INodeSlot<br/>slot_index: SlotIndex"]
    Exec["ExecutableNodeDTO<br/>id: ExecutionId"]
  end

  subgraph AP["🌐 App-domain entities"]
    direction TB
    Workflow["ComfyWorkflow<br/>id: WorkflowId"]
    Job["Prompt / Job<br/>id: JobId ≡ PromptId"]
    Task["Task<br/>id: TaskId"]
    Asset["Asset<br/>id: AssetId"]
    Workspace["Workspace<br/>id: WorkspaceId"]
    Invite["WorkspaceInvite<br/>id: WorkspaceInviteId"]
    User["User<br/>id: UserId"]
    Pack["NodePack<br/>id: NodePackId"]
  end

  Subgraph -. extends .-> Graph
  Graph --> Node
  Graph --> Link
  Graph --> Group
  Node --> Slot
  Link --> Reroute
  Link --> Slot
  Node -. instantiates .-> Subgraph
  Exec -. wraps .-> Node

  Workflow -. serializes .-> Graph
  Job --> Workflow
  Job --> Task
  Task --> Asset
  Workspace --> Workflow
  User --> Workspace
  Workspace --> Invite
  Pack -. provides .-> Node
```

Solid arrows are containment / direct references; dashed arrows are *“is
a kind of”* (`extends`) or cross-layer relationships (e.g. a
`ComfyWorkflow` *serializes* an `LGraph`; a `NodePack` *provides* node
definitions).

## Explicit non-goals

- `LGraphState.lastNodeId` is intentionally kept as bare `number`
(auto-increment counter; would widen if aliased to `NodeId = number |
string`).
- No new `SubgraphSlotId` alias — verified subsidiary (subgraph IO slots
are addressed via `SUBGRAPH_INPUT_ID/OUTPUT_ID` sentinel + numeric array
index, not by UUID alone).
- No `WidgetName`, `SlotName`, `WorkspaceMemberId` — verified subsidiary
(only meaningful inside a parent or as a relationship).
- No re-typing of `LGraph.id` / `Subgraph.id` — references adopt
`SubgraphId`, but the inherited UUID typing is left intact (minimal
diff).

## Type-only

All changes are structural-equivalent type aliases. No runtime behavior
changes. No new exports beyond the aliases themselves. No generated code
modified.

## Verification

- `pnpm typecheck` 
- `pnpm knip` 
- Scoped `npx eslint` on changed files 
- Lint-staged hooks (oxfmt, oxlint, eslint, typecheck) passed on every
commit

## Notes for reviewers

This branch was rebased onto `main` after FE-165 (`a441364a5`, PR
#11698) merged independently — the auto-skipped FE-165 commit is no
longer part of this PR. Six commits remain (oldest → newest):

| Commit | Maps to | Summary |
| --- | --- | --- |
| `e8e7ff795` | FE-166 | Add `GroupId` and `SlotIndex` aliases + barrel
re-exports |
| `e0bcb75a0` | FE-476 | Add `SubgraphId = UUID` alias |
| `2c136afb9` | FE-477 + FE-475 bulk | Add app-domain aliases;
mechanical adoption of
`NodeId`/`LinkId`/`RerouteId`/`GroupId`/`SlotIndex`/`ExecutionId`/`SubgraphId`
at audit-listed litegraph sites |
| `06d6e6a8b` | FE-477 | Adopt `TaskId` in asset stores |
| `f943e1c2b` | FE-476 | Adopt `SubgraphId` at remaining UUID reference
sites (`LGraphCanvas` clipboard map + paste, `SubgraphNode.type`) |
| `1739d5241` | review feedback | Tighten alias usage:
`linkExtensions.parentId: RerouteId`, drop redundant `String()` wraps in
`executionStore`, type `assetExportStore` map as `Map<TaskId,
AssetExport>` |

FE-475's mechanical adoption is bundled into `2c136afb9` rather than a
dedicated commit (parallel-agent execution on a shared working tree);
the substitutions themselves are complete — see the diff under
`src/lib/litegraph/src/`. PR will be squash-merged, so commit
granularity is informational.

Fixes FE-166
Fixes FE-475
Fixes FE-476
Fixes FE-477

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 21:36:06 -07:00
Christian Byrne
963a7bf178 refactor: consolidate browser_tests/helpers/ into fixtures/ (#11411)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Eliminates the confusing dual-helpers structure where
`browser_tests/helpers/` and `browser_tests/fixtures/helpers/` coexisted
one tier apart with overlapping purposes
- Routes each file to its natural home based on what it actually *is*:
page objects → `components/`, standalone utils → `utils/`, domain helper
classes stay in `helpers/`
- Adds an ESLint guard (`no-restricted-imports`) to prevent re-creating
`browser_tests/helpers/`

## File Moves

| File | From | To | Reason |
|---|---|---|---|
| `actionbar.ts` | `helpers/` | `fixtures/components/Actionbar.ts` |
Page object class imported by ComfyPage |
| `templates.ts` | `helpers/` | `fixtures/components/Templates.ts` |
Page object class imported by ComfyPage |
| `boundsUtils.ts` | `fixtures/helpers/` | `fixtures/utils/` | Pure
function, not a helper class |
| `mimeTypeUtil.ts` | `fixtures/helpers/` | `fixtures/utils/` | Pure
function, not a helper class |
| `builderTestUtils.ts` | `helpers/` | `fixtures/utils/` | Shared test
setup functions |
| `clipboardSpy.ts` | `helpers/` | `fixtures/utils/` | Page injection
utility |
| `fitToView.ts` | `helpers/` | `fixtures/utils/` | Canvas utility
function |
| `manageGroupNode.ts` | `helpers/` | `fixtures/utils/` | Litegraph
interaction helper |
| `painter.ts` | `helpers/` | `fixtures/utils/` | Test helper functions
|
| `perfReporter.ts` | `helpers/` | `fixtures/utils/` | Test
infrastructure |
| `promotedWidgets.ts` | `helpers/` | `fixtures/utils/` | Query helpers
for specs |

## What Changed Beyond File Moves

- **28 import statements** updated across test specs, fixtures, and
infra files
- **AGENTS.md** — directory tree diagram and architectural separation
descriptions updated
- **README.md** — "Leverage Existing Fixtures and Helpers" section
updated
- **`.claude/skills/perf-fix-with-proof/SKILL.md`** — perfReporter path
reference updated
- **`eslint.config.ts`** — added `@e2e/helpers/*` restricted import
pattern to both spec and non-spec browser_tests rules

## Verification

- `pnpm typecheck` — clean
- `pnpm typecheck:browser` — clean
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm format:check` — all files formatted
- `pnpm knip` — clean
- Pre-commit hooks passed full pipeline (oxfmt, oxlint, eslint,
typecheck, typecheck:browser)

## Config Audit

No changes needed to: `tsconfig.json` (`@e2e/*` alias covers all
subdirs), `playwright.config.ts`, `vite.config.mts`, `knip.config.ts`,
`.oxlintrc.json`, `nx.json`

## Manual Verification Note

This is a pure structural refactoring (file moves + import updates) with
zero behavioral or visual changes. The typecheck and lint passes confirm
all imports resolve correctly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11411-refactor-consolidate-browser_tests-helpers-into-fixtures-3476d73d3650816cb671ef7fa8433f66)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 02:18:31 +00:00
Terry Jia
f001bc9af3 feat: derive Preview3D camera pose from EXTRINSICS/INTRINSICS matrices (#11626)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
splat workflows (and PLY pipelines that go through SHARP / COLMAP) emit
camera pose as extrinsics + intrinsics matrices, and the viewer needs a
way to consume them before that end-to-end story can ship.

## Summary

Adds the ability to drive the Preview3D camera from a pair of
OpenCV-convention extrinsics + intrinsics matrices (as produced by SHARP
/ COLMAP / other SfM pipelines), so backend nodes that emit such
matrices can position the viewer's camera deterministically. Fifth in
the series splitting up
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495. Fully
self-contained — adds new API surface only, no existing call paths are
modified.

## Changes

- **What**:
- `cameraFromMatrices.ts` (new): pure function
`computeCameraFromMatrices(extrinsics, intrinsics)` returning `{
position, target, fovYDegrees }`. No THREE.js dependency.
Shape-validates the inputs (4×4 and 3×3) with a clear error.
- `Load3d.setCameraFromMatrices(extrinsics, intrinsics)`: wires the
utility into Load3d via `setCameraState` + `setFOV`. Preserves the
caller's existing `zoom` and `cameraType`.
- `load3d.ts` (extension): Preview3D's `onExecuted` extracts
`extrinsics`/`intrinsics` from `result[3]`/`result[4]` (when present)
and calls `setCameraFromMatrices`. The output tuple type is widened to
include the two optional `Matrix` fields.

## Review Focus

- **Convention conversion**: OpenCV is Y-down, Z-forward; three.js is
Y-up, Z-back. The function flips Y and Z on both `position` and `target`
(equivalent to a 180° X-axis rotation of the whole world). The same
rotation is applied elsewhere to splats at load time, so a future splat
+ camera-pose pair lines up.
- **FOV math**: vertical FOV (radians) = `2 * atan(cy / fy)`. We expose
it in degrees, matching `cameraManager.setFOV`'s contract.
- **Camera-state preservation**: `setCameraFromMatrices` reads the
current state to keep `zoom` and `cameraType` intact — only `position`,
`target`, and `fov` come from the matrices.
- **Backwards compatibility**: backend nodes that don't return matrices
simply skip the new branch (the `if (extrinsics && intrinsics)` guard).
Existing Preview3D workflows are unaffected.
- 7 unit tests for the matrix math (identity, rotation, translation, FOV
derivation, shape errors, etc.); 1 unit test for the Load3d wiring
(verifies the destructured `position`/`target`/`fovYDegrees` reach
`setCameraState` + `setFOV` with `zoom`/`cameraType` preserved).

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `cameraFromMatrices.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | ~7.6% | 0% | ~14.6% | ~7.7% |
| `load3d.ts` (extension, modified) | 0% | 0% | 0% | 0% |

The `Load3d.ts` numbers are the pre-existing baseline — `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 new `setCameraFromMatrices` method body is exercised
by a new unit test that asserts `setCameraState` receives the
destructured matrix output and `setFOV` receives the computed
`fovYDegrees`, with the caller's `zoom`/`cameraType` preserved.

`load3d.ts` (the extension registration file, 0% on `main` and after
this PR) has no unit-test scaffolding in the project — its `onExecuted`
handler runs only after a workflow execution and is exercised end-to-end
via browser tests. The new `if (extrinsics && intrinsics)
load3d.setCameraFromMatrices(...)` branch sits in that path.

Net: the matrix math itself, which previously didn't exist, is now 100%
covered. The wiring layer relies on the same e2e safety net the
surrounding code has always used.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11626-feat-derive-Preview3D-camera-pose-from-EXTRINSICS-INTRINSICS-matrices-34d6d73d3650817297bdf25a83a4f7a2)
by [Unito](https://www.unito.io)
2026-04-27 21:58:12 -04:00
Kelly Yang
88faaf3d86 test: complete remaining Painter widget E2E tests (#11613)
## Summary

Implemented E2E test coverage for Levels 6-12 of the Painter Widget

## Changes

Adds the following coverage to complete the test plan:

Level 6 - Input image connection (3 tests, @slow):
  - Width/height/bg-color controls hide when input is linked
  - Canvas resizes to match input image dimensions after execution
  - Drawing over input image produces canvas content

Level 7 - Clear on empty canvas is harmless

Level 8 - Unchanged canvas does not re-upload on second serialization

Level 9 - Settings persistence:
  - Tool selection saved to node.properties.painterTool
  - Brush size change saved to node.properties.painterBrushSize

Level 10 - Compact layout collapses to grid-cols-1 when node width <
350px

Level 12 - Rapid drawing accumulates all strokes (checks 3 y-positions)

Supporting changes:
- Add data-testid="painter-controls" to controls grid in
WidgetPainter.vue (needed for compact mode class assertion)
- Add browser_tests/assets/widgets/painter_with_input.json workflow
fixture (LoadImage connected to Painter input slot 0)



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mostly adds/adjusts Playwright and unit test coverage; the only
runtime change is wrapping pointer-capture calls in `try/catch`, which
is low-risk but touches input-handling paths.
> 
> **Overview**
> Completes and expands Painter widget browser test coverage, including
new scenarios for clearing an empty canvas, preventing redundant uploads
when serializing an unchanged canvas, persisting tool/brush-size
settings to node properties, compact layout behavior, multi-stroke
accumulation checks, and an input-image-connected workflow (new
`painter_with_input.json`) with execution/resizing/draw-over-image
assertions.
> 
> Hardens `usePainter` pointer handling by tolerating
`setPointerCapture`/`releasePointerCapture` failures (e.g., synthetic
events), with corresponding unit tests updated/added to validate the
behavior and serialization expectations.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
056d4a9f0c. 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-11613-test-complete-remaining-Painter-widget-E2E-tests-34c6d73d36508158b620f55aa1981cf5)
by [Unito](https://www.unito.io)
2026-04-27 21:41:45 -04:00
Terry Jia
42ff7b6c62 refactor(load3d): drive viewer behavior from ModelAdapter capabilities (#11660)
> Final architectural step in the PLY / 3D Gaussian Splatting series.
Previous PR introduced `ModelAdapter` with a dormant `capabilities`
field; this PR makes those capabilities load-bearing and replaces the
remaining `instanceof SplatMesh` / `instanceof BufferGeometry` switches
with adapter-driven dispatch. Together with previous one it removes the
last of the format-specific branching from `SceneModelManager` /
`Load3d`. Seventh in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Summary

Drive viewer behavior (fit-to-viewer, default camera pose, world bounds,
GPU dispose, material rebuild) from `ModelAdapterCapabilities` + 3 new
optional adapter methods, instead of `SceneModelManager` reflecting on
model shape. `Load3d` is rewritten to take its 13 managers as injected
`Load3dDeps`; a new `createLoad3d` factory assembles them and threads a
single `AdapterRef` between `LoaderManager` (writer) and
`SceneModelManager` + `Load3d` (readers). Splat orientation +
decoder-race + sizing bugs are fixed as a side effect — splats now
render upright, fill the grid, and don't lock the OrbitControls target
on first frame.

## Changes

- **`ModelAdapter.ts`**: add `AdapterRef = { current: ModelAdapter |
null }` shared handle and 3 optional adapter methods —
`computeBounds(model)`, `disposeModel(model)`, `defaultCameraPose()`.
- **`SplatModelAdapter.ts`**: implement all 3 optional methods; `await
splatMesh.initialized` so first-frame bounds are populated (fixes a
decoder race that collapsed the OrbitControls target onto the camera);
`quaternion.set(1, 0, 0, 0)` to convert sparkjs's OpenCV (Y-down,
Z-forward) to three.js (Y-up, Z-back); flip `fitToViewer` back to `true`
and bump `fitTargetSize` to `20` so splats fill the 20-unit grid instead
of shrinking to 1/4 of it.
- **`PointCloudModelAdapter.ts`**: extract
`buildPointCloudForMaterialMode` so `SceneModelManager` rebuilds via the
same code path the initial load uses; `setPath` so PLYs that reference
relative assets resolve correctly.
- **`LoaderManager.ts`**: accept optional `AdapterRef`; write through it
instead of the internal `_currentAdapter` field. `clearModel()` now runs
while the old adapter is still current so its `disposeModel()` can
release renderer-owned resources.
- **`SceneModelManager.ts`**: accept 4 capability lambdas
(`getCurrentCapabilities` / `getBoundsFromAdapter` /
`disposeModelViaAdapter` / `getDefaultCameraPose`) with
`DEFAULT_MODEL_CAPABILITIES` / null fallbacks. `setupModel`,
`fitToViewer`, and material-mode rebuild are now capability-driven;
`containsSplatMesh` (30 lines of `instanceof` traversal) and
`handlePLYModeSwitch` (90 lines of duplicated PLY rebuild) are gone.
- **`Load3d.ts`**: ctor switches from manager-creation to deps-injected
(`Load3dDeps`); add `getCurrentModelCapabilities()` reader; gate
`setGizmoEnabled` / `setGizmoMode` / `resetGizmoTransform` /
`applyGizmoTransform` on `capabilities.gizmoTransform`; `isSplatModel` /
`isPlyModel` now read `adapterRef.current?.kind` directly.
- **`createLoad3d.ts`** (new): single factory that builds the renderer
(`createRenderer`), assembles all 13 managers in dependency order, and
threads one shared `AdapterRef` through `LoaderManager` and
`SceneModelManager`'s 4 capability lambdas.
- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: switch from `new
Load3d(container, options)` to `createLoad3d(container, options)`. No
other call-site changes.

## Review Focus

- **Capability dispatch parity**: walk each former hardcoded branch in
`SceneModelManager` and confirm it now falls out of the right
capability:
- `containsSplatMesh()` → `!capabilities.fitToViewer` +
`getDefaultCameraPose()`
- `handlePLYModeSwitch()` → `capabilities.requiresMaterialRebuild` +
`buildPointCloudForMaterialMode()`
- `Box3.setFromObject(model)` for sizing → `getBoundsFromAdapter(model)
?? Box3.setFromObject(model)`
- Mesh/Points geometry+material disposal in `clearModel` → still
happens, plus `disposeModelViaAdapter(obj)` for adapter-owned resources
(sparkjs SplatMesh internal GPU state)
- **`AdapterRef` lifecycle**: one ref is created in `createLoad3d`,
passed to `LoaderManager` (writer) and `SceneModelManager` (read via 4
closures). `LoaderManager.loadModel` clears via the *old* adapter first
(so `disposeModel` runs), then null-resets the ref before picking the
new one. Test `keeps the old adapter current while clearModel runs` pins
this ordering.
- **Splat fixes are user-visible, not pure refactor**:
- Orientation: `quaternion.set(1, 0, 0, 0)` matches the sparkjs README
convention. Without it splats render upside-down and mirrored on Z. Same
rotation is applied to the camera-from-matrices output in PR-E so a
future splat + camera-pose pair lines up.
- Decoder race: `await splatMesh.initialized` ensures `getBoundingBox`
returns a non-zero box on the first call. Without it `setupModel`'s
bounds → camera pipeline placed the OrbitControls target on the camera
origin, locking the view.
- Sizing: `fitTargetSize: 20` (vs. the mesh default of 5) means splat
geometry spans the full 20-unit grid footprint instead of ~1/4 of it.
Mesh assets are unaffected.
- **Gizmo gating**: `setGizmoEnabled(true)` early-returns when
`capabilities.gizmoTransform` is false. Internal
`setGizmoEnabled(false)` still runs (so we can always disable).
`setGizmoMode` / `resetGizmoTransform` / `applyGizmoTransform` no-op
when the capability is off.
- **`createLoad3d` is the single ctor entry**: `new Load3d(...)` is no
longer callable from app code (ctor signature changed to `(container,
deps, options)`). All call sites use `createLoad3d`. Test scaffolding
still uses `Object.create(Load3d.prototype)` + property injection where
it needs to bypass renderer creation.
- **Backwards compatibility**: `LoaderManager`'s `adapterRef` and
`SceneModelManager`'s 4 capability lambdas all have defaults
(`createAdapterRef()` and `() => DEFAULT_MODEL_CAPABILITIES` etc.), so
the existing test suites that construct these classes with the old
signatures still compile and pass without modification beyond what's in
this PR.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.7% | 86.7% | **100%** |
| `MeshModelAdapter.ts` (unchanged) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (modified) | **97.9%** | 69.2% | 71.4% |
**97.9%** |
| `SplatModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `SceneModelManager.ts` (modified) | 75.4% | 67.2% | 72.2% | 75.4% |
| `Load3d.ts` (modified) | 29.5% | 30.6% | 26.7% | 30.1% |
| `createLoad3d.ts` (new) | 83.8% | **100%** | 58.3% | 83.8% |
| `useLoad3d.ts` (modified) | 78.2% | 65.1% | 71.4% | 82.2% |
| `useLoad3dViewer.ts` (modified) | 75.2% | 52.1% | 65.9% | 79.4% |

`SplatModelAdapter.ts` jumps to 100% via 6 new tests covering the
orientation set, the `await initialized` decoder wait, `computeBounds`
(world-space transform + null fallback), `disposeModel` (per-SplatMesh
dispose + no-op on non-splat trees), and `defaultCameraPose`.

`createLoad3d.ts` hits 100% branch via a new test file with 12 cases —
`WebGLRenderer` config, `Load3DOptions` forwarding, `AdapterRef`
identity between `LoaderManager` and `SceneModelManager`, and the 4
capability lambdas in both adapter-null and adapter-published states
(each delegates correctly to the adapter's optional methods or falls
back to defaults). The remaining func% reflects the inline
`gizmoTransformChange` callback — not a deliberate skip, just out of
scope for the dispatch-wiring tests.

`SceneModelManager.ts` and `Load3d.ts` numbers are the pre-existing
baseline — the existing `*.test.ts` files cover façade methods via
prototype injection rather than instantiating the classes (`Load3d`
constructor needs `THREE.WebGLRenderer`, which happy-dom can't provide;
`SceneModelManager` covers the new capability paths via its existing
`createManager(overrides)` helper). All new branches (capability gating,
capability-driven `setupModel` / `fitToViewer` / rebuild, adapter-driven
`isSplatModel` / `isPlyModel`) have dedicated tests.

Net diff: **+846 / −370** across 16 files (10 production, 6 test).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11660-refactor-load3d-drive-viewer-behavior-from-ModelAdapter-capabilities-34f6d73d36508130b0ece884add182b9)
by [Unito](https://www.unito.io)
2026-04-27 21:32:21 -04:00
Dante
f404887c96 test: add unit tests for linkFixer (#11668)
## Summary

Adds unit coverage for `linkFixer` serialized graph repair paths and
fixes input-slot repairs being tracked without mutating the serialized
workflow data.

## Changes

- **What**: Adds 10 Vitest cases for valid links, dry-run reporting,
origin/target repair, missing input-slot cleanup, dangling node cleanup,
stale link deletion, and silent mode.
- **What**: Applies serialized input slot mutations in fix mode and
reports `fixed` only after a rerun confirms the graph is clean.
- **What**: Adds the design-system package to knip workspace config so
the pre-push hook recognizes its exported CSS dependency usage.
- **Dependencies**: None.

## Review Focus

The workflow validation path calls `fixBadLinks` with serialized
workflow JSON, so the new tests stay at that boundary and assert the
repaired graph shape directly.

## Test Coverage

- CI snapshot for `src/utils/linkFixer.ts`: unit lines 1.8%.
- Local targeted coverage: unit lines 83.9%, functions 94.11%, branches
75.15%.

## Testing

```bash
pnpm format -- src/utils/linkFixer.ts src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts --coverage
pnpm typecheck
pnpm lint
pnpm knip
```


## screenshoot
<img width="1691" height="927" alt="Screenshot 2026-04-28 at 10 09
26 AM"
src="https://github.com/user-attachments/assets/ff59888f-bde3-48f5-853a-60df69e84492"
/>
2026-04-28 01:11:25 +00:00
Comfy Org PR Bot
b37c66539b 1.44.12 (#11705)
Patch version increment to 1.44.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11705-1-44-12-3506d73d36508121bc39e748c87a6235)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-28 00:38:05 +00:00
Alexander Brown
95e9a9405b test(subgraph): pin #10849 promoted-widget-value corruption with it.fails (#11697)
*PR Created by the Glary-Bot Agent*

---

## Summary

#11579 restored *categorical* test coverage for subgraph serialization
but didn't reproduce the specific Z-Image-Turbo regression introduced by
#10849 — pre-#10849 templates whose `widgets_values` is leftover noise
get corrupted on load because the new code applies that array
positionally to promoted widget views.

This PR adds two **vitest** cases that pin the user-visible symptom
directly: after loading a misaligned legacy payload, the promoted widget
value should reflect the source default, not the legacy
`widgets_values[i]`.

Both use `it.fails` so the suite stays green while the bug is present
and flips to failing the moment the fix on
`fix/subgraph-promoted-widget-inline-state` lands.

## Tests

1. `falls back to source widget value when proxyWidgets is in legacy
2-tuple shape` — configure() with `proxyWidgets: [['-1', 'widget']]` +
`widgets_values: [999]` should leave the widget at the source default
(42), not 999.
2. `does not corrupt unbound promoted widgets when widgets_values length
mismatches view count` — same shape with longer/wrong-length array.

## Verification

- All 8 cases in the file pass under `it.fails` (CI green).
- Removing `.fails` locally produces the expected failures: `expected
42, received 999` and `expected 42, received 111` — confirming both
tests catch the regression.
- `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxlint` all clean.

## Why `it.fails` and not plain failing tests

The actual fix (`fix/subgraph-promoted-widget-inline-state`) is
unmerged. Landing genuinely-failing tests on main would break CI for
everyone. `it.fails` documents the bug runnably, keeps CI green, and
signals when the fix lands so the marker can be dropped.

## Why these assertions, not "widgets_values must be undefined"

A first draft asserted that `serialize()` should not write
`widgets_values` at all. That conflicts with existing coverage in the
same file (`preserves per-instance widget values after configure`,
`round-trips per-instance widget values`) which deliberately uses
`widgets_values` for round-trip persistence. These rewritten assertions
target the load-time corruption symptom directly without contradicting
the per-instance contract — feedback from Oracle review.

## Coordination

Mirrors the failing tests already on commit `6a982675e` of the unmerged
`fix/subgraph-promoted-widget-inline-state` branch, with the addition of
`.fails` markers and a clarifying comment so they can land on main
first.

Follow-up to #11579.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11697-test-subgraph-pin-10849-promoted-widget-value-corruption-with-it-fails-34f6d73d365081d7a04dcf48ebeceafe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-28 00:13:48 +00:00
Dante
00974d6339 test: add E2E tests for publish flow wizard (#10770)
## Summary
- Add Playwright E2E test coverage for the ComfyHub publish workflow
dialog
- Create `PublishDialog` page object fixture with programmatic dialog
opening via Vite dynamic imports
- Create `PublishApiHelper` for mocking all publish flow API endpoints
(`/hub/profiles/me`, `/hub/labels`, `/hub/workflows`,
`/userdata/*/publish`, `/assets/from-workflow`,
`/hub/assets/upload-url`)
- Add `data-testid` attributes to 6 publish flow components for stable
E2E locators
- 17 test scenarios across 7 describe blocks covering wizard navigation,
form interactions, profile gate, save prompt, and publish submission

## Test plan
- [ ] Run `pnpm test:browser:local -- --grep "Publish dialog"` against
local dev server
- [ ] Verify wizard navigation through Describe → Examples → Finish
steps
- [ ] Verify profile gate flow (with/without profile)
- [ ] Verify save prompt for unsaved workflows
- [ ] Verify publish success/failure scenarios

Fixes #9079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10770-test-add-E2E-tests-for-publish-flow-wizard-3346d73d3650818094d5fc3a84593402)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante <dante@danteui-MacStudio.local>
Co-authored-by: GitHub Action <action@github.com>
2026-04-28 00:13:43 +00:00
Dante
878ffb70cc test: add unit tests for assetService (#11670)
## Summary

Extends `assetService.test.ts` (which previously only covered
`shouldUseAssetBrowser`) with behavioral tests for the network-bound
methods: metadata fetch error mapping, base64 upload validation,
async-vs-sync upload routing, delete error propagation, model-folder
filtering, update validation, and tag-filtered list defaults.

## Changes

- **What**: Adds 10 Vitest cases across `getAssetMetadata`,
`uploadAssetFromBase64`, `uploadAssetAsync`, `deleteAsset`,
`getAssetModelFolders`, `updateAsset`, and `getAssetsByTag`. Reuses the
existing hoisted `mockDistributionState` / `mockSettingStoreGet` setup
and the existing `vi.mock('@/scripts/api')` boundary; adds local
`buildResponse` and `validAsset` helpers scoped to this file.

## Review Focus

- The localized error path is covered through public methods
(`getAssetMetadata`) rather than reaching for the internal
`getLocalizedErrorMessage`.
- `getAssetModelFolders` test asserts that the request URL omits
`include_public` (the internal call site passes no `includePublic`),
matching the conditional in `handleAssetRequest`.
- `uploadAssetAsync` tests pin the discriminated-union shape (`type:
'async' | 'sync'`) for both 202 and 200 responses.

## Testing

\`\`\`bash
pnpm exec vitest run src/platform/assets/services/assetService.test.ts
pnpm format -- src/platform/assets/services/assetService.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

All 16 tests pass (6 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11670-test-add-unit-tests-for-assetService-34f6d73d36508117b1aafaf463e9c820)
by [Unito](https://www.unito.io)
2026-04-28 08:45:01 +09:00
Alexander Brown
a441364a55 refactor(litegraph): centralize _version counter via incrementVersion() (#11698)
## Summary

Centralize all `LGraph._version` increments behind a single
`incrementVersion()` method to create the seam for a future
`VersionSystem` (ECS Migration Phase 0a).

## Changes

- **What**: Added `LGraph.incrementVersion()` and replaced all 19 direct
`graph._version++` writes across 7 files. Existing null guards at call
sites are preserved. Zero behavioral change — the counter is still only
read by `LGraphCanvas.renderInfo()` for debug display.

## Review Focus

- The new method is mechanical: `incrementVersion(): void {
this._version++ }`. Look for any sites I missed or null-guard
regressions.
- Files updated: `LGraph.ts`, `LGraphNode.ts`, `LGraphCanvas.ts`,
`widgets/BaseWidget.ts`, `subgraph/SubgraphInput.ts`,
`subgraph/SubgraphInputNode.ts`, `subgraph/SubgraphOutput.ts`.

Part of the [ECS Migration
Plan](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/ecs-migration-plan.md).
Linear: FE-165.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11698-refactor-litegraph-centralize-_version-counter-via-incrementVersion-34f6d73d3650810992f8fa3adbae3f38)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 23:25:41 +00:00
Alexander Brown
8dcadd6fe1 test: restore deleted subgraph serialization E2E tests (#11579)
*PR Created by the Glary-Bot Agent*

---

## Summary

#10759 removed ~12 E2E tests from `subgraphSerialization.spec.ts` during
a reorganization that shifted semantic coverage to Vitest. Several of
the removed tests covered the exact `serialize() → JSON → configure()`
round-trip that #10849's positional `_instanceWidgetValues` path later
regressed on Main — promoted widget values binding to the wrong slots
when loading templates whose `widgets_values` ordering doesn't match
current `proxyWidgets`.

This restores the pre-reorg E2E coverage so future regressions in
promoted-widget serialization are caught at the browser level.

## Restored tests

From `subgraphSerialization.spec.ts` (pre-#10759):

- **Deterministic proxyWidgets Hydrate** (3 tests) — round-trip
stability and compressed `target_slot` resolution.
- **Legacy And Round-Trip Coverage** (5 tests) — includes the most
directly-relevant restorations:
  - `Promoted widgets survive serialize -> loadGraphData round-trip`
  - `Multi-link input representative stays stable through save/reload`
- `Cloning a subgraph node keeps promoted widget entries on original and
clone`
- **Duplicate ID Remapping** (5 tests) — includes `Promoted widget
tuples are stable after full page reload boot path`.

The 4 tests that already existed on Main (added by #10849 and the
Vue-nodes legacy-prefixed block) are kept as-is.

## Adaptations to current APIs

- Imports reworked for the post-`@e2e/*` alias layout.
- Redundant `comfyPage.nextFrame()` calls dropped — `loadWorkflow` /
`loadGraphData` / `serializeAndReload` already wait internally (#11264).
- Alt-drag clone block wrapped in `try/finally` around
`keyboard.up('Alt')` to match the current `subgraphCrud.spec.ts`
pattern.
- `PromotedWidgetEntry` is now exported from
`browser_tests/helpers/promotedWidgets.ts` so the restored
`expectPromotedWidgetsToResolveToInteriorNodes` helper can type its
argument.

## Review follow-ups applied

- Use `expect.poll()` instead of `expect(async () => …).toPass()` for
the single-value snapshot comparison, per `browser_tests/AGENTS.md`.
- Capture and call `dispose()` from
`SubgraphHelper.collectConsoleWarnings()` inside a `try/finally` so the
console listener is unregistered after the test.

## Verification

- `pnpm typecheck:browser` — clean.
- `pnpm exec eslint` + `pnpm exec oxlint` on changed files — 0 warnings,
0 errors.
- `pnpm exec oxfmt` on changed files — applied (no diff).
- Ran 2 key restored tests against local ComfyUI + dev server:
- `Promoted widgets survive serialize -> loadGraphData round-trip` —
PASS (3.9s)
- `Multi-link input representative stays stable through save/reload` —
PASS (2.9s)
- Full-suite runs in this sandbox are blocked by the existing
`comfyPage` fixture's `createUser` path failing on repeat runs against
persistent backend state — unrelated to this PR. CI will exercise the
full suite.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11579-test-restore-deleted-subgraph-serialization-E2E-tests-34b6d73d365081f29b27c1069476ad17)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 21:31:59 +00:00
Simon Pinfold
3a05a37323 Fix naming strategy for multi-job asset exports (#11610)
## Summary

Use `group_by_job_time` for exports spanning multiple jobs while keeping
single-job exports on `preserve`, and add regression coverage for the
new naming-strategy behavior.

## Changes

- **What**: updated the asset export payload and request typing for the
new naming-strategy values, added unit coverage for single-job vs
multi-job export requests, added `@cloud` sidebar browser coverage for
export payloads, and adjusted the cloud Playwright setup helpers so
setup API calls can hit the backend directly and Firebase auth is seeded
on the app origin
- **Breaking**: none
- **Dependencies**: none

## Review Focus

Please sanity-check the cloud Playwright harness changes in `ComfyPage`
and `CloudAuthHelper`, plus the single-job vs multi-job export
naming-strategy assertions in the new browser tests.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11610-Fix-naming-strategy-for-multi-job-asset-exports-34c6d73d365081a68a88ea38d897578f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-27 20:44:32 +00:00
Dante
6cfa5fdb1f test: add unit tests for assetsStore (#11672)
## Summary

Extends `assetsStore.test.ts` with behavioral coverage for the
optimistic-update flows (`updateAssetMetadata`, `updateAssetTags`), the
per-asset deletion-state tracker (`setAssetDeleting` /
`isAssetDeleting`), the input-name resolution (`getInputName` /
`inputAssetsByFilename`), and the cloud-routing branch of
`updateInputs`.

## Changes

- **What**: Adds 8 Vitest cases across 4 new `describe` blocks
(`updateAssetMetadata optimistic cache`, `updateAssetTags diff-based
dispatch`, `setAssetDeleting / isAssetDeleting`, `getInputName`,
`updateInputs cloud routing`). Extends the shared `assetService` mock
with `updateAsset`, `addAssetTags`, and `removeAssetTags` (none of which
were previously stubbed).

## Review Focus

- Cloud-routed tests flip `mockIsCloud.value` inside a `try/finally` so
the existing default (`false`) is restored even if an assertion throws —
same pattern the existing Cloud describe block uses.
- The optimistic-cache rollback test silences the `console.error`
invoked by the catch branch so the test output stays clean.
- The `updateAssetTags` tests pin both the no-op short-circuit and the
add-only path. Remove-only and combined add+remove are already exercised
indirectly through the existing tag-cache invalidation tests.

## Testing

\`\`\`bash
pnpm exec vitest run src/stores/assetsStore.test.ts
pnpm format -- src/stores/assetsStore.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

47 tests pass (39 prior + 8 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11672-test-add-unit-tests-for-assetsStore-34f6d73d3650819f8e43e7154541baef)
by [Unito](https://www.unito.io)
2026-04-27 20:40:27 +00:00
Benjamin Lu
3ea75e1c48 fix: localize secret date labels (#11524)
## Summary

Localizes secret list date labels through Vue I18n date formatting
instead of the browser default numeric date format, with Storybook
coverage for design review.

## Changes

- **What**: Replaces `toLocaleDateString()` with `useI18n().d(..., {
dateStyle: 'medium' })` for `SecretListItem` dates.
- **What**: Updates `SecretListItem` expectations to match the Vue I18n
date formatter.
- **What**: Adds `SecretListItem` stories for default, never-used,
loading, and disabled states.
- **Dependencies**: None.

## Review Focus

Stacked on #11480 to keep the escaping fix scoped. Please confirm
whether the localized medium date style matches design/product
expectations.

## Screenshots (if applicable)


https://f3ba7229.comfy-storybook.pages.dev/?path=/story/platform-secrets-secretlistitem--default

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11524-fix-localize-secret-date-labels-3496d73d3650814bb70bfb3f870a31cd)
by [Unito](https://www.unito.io)
2026-04-27 13:11:31 -07:00
Alexander Brown
9c0edd0048 fix: prevent duplicate website-e2e CI runs on PRs (#11607)
## Summary

Fix duplicate `CI: Website E2E` workflow runs on pull requests.

## Problem

Two runs were triggered for every PR touching website files:
- `website-e2e (pull_request)` — from the PR event
- `website-e2e (push)` — from the push to a `website/*` branch

The concurrency key used `github.ref`, which evaluates differently for
push (`refs/heads/...`) vs pull_request (`refs/pull/N/merge`), so they
couldn't cancel each other.

## Changes

1. Scope `push` trigger to `main` only (removes `website/*`)
2. Use `github.head_ref || github.ref` in the concurrency group so push
and PR events for the same branch share a group

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11607-fix-prevent-duplicate-website-e2e-CI-runs-on-PRs-34c6d73d3650814c9d24c77b1591e94a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 19:55:05 +00:00
Benjamin Lu
7ee667c1d1 fix: avoid escaped secret date labels (#11480)
Global i18n config has escapeParameter as true. This explicitly turns it
to false. I opened a Linear ticket to reconsider changing this back to
false as default globally.

## Summary

Fix the Secrets panel so created and last-used dates render as plain
text instead of HTML-escaped slash entities.

## Changes

- **What**: Compute the Secrets date labels with `t(..., {
escapeParameter: false })` after formatting the date, so vue-i18n does
not escape `/` into `&#x2F;` for plain-text output.
- **What**: Replace the mocked translation setup in
`SecretListItem.test.ts` with a real `vue-i18n` instance and add a
regression test that asserts the rendered dates do not contain escaped
slash entities.

## Review Focus

This intentionally fixes the i18n interpolation issue shown in the bug
screenshot. It does not change the separate RFC3339Nano parsing behavior
discussed in #11358.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11480-fix-avoid-escaped-secret-date-labels-3486d73d365081c890ecd2a6992d7879)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:46:01 -07:00
Benjamin Lu
2b010ac8b3 fix: dedupe pending checkout attempt construction (#11622)
## Summary

Deduplicates pending subscription checkout attempt construction so
storage and fallback paths share the same payload creation.

## Changes

- **What**: Build the `PendingSubscriptionCheckoutAttempt` once in
`recordPendingSubscriptionCheckoutAttempt()` and reuse it for
unavailable-storage, failed-write, and successful-write paths.
- **Dependencies**: None.

## Review Focus

This is intended as a no-behavior-change cleanup: unavailable storage
still returns an attempt, failed `setItem()` still returns that attempt
without dispatching, and the pending-checkout event only fires after a
successful storage write.

Linear: FE-209

## Screenshots (if applicable)

N/A

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:28:18 -07:00
jaeone94
b4d209b5f6 feat: refresh missing models through pipeline (#11661)
## Summary

Follow-up to the closed earlier attempt in #11646. This PR keeps the
same user-facing goal, but changes the implementation to reuse the
existing missing model pipeline for refresh instead of maintaining a
separate candidate-only recheck path.

Adds a missing model refresh action in the Errors tab by reusing the
existing missing model pipeline, so users can re-check models after
downloading or manually placing files without reloading the workflow.

## Changes

- **What**:
- Adds `app.refreshMissingModels()` as a reusable refresh entry point
for the current root graph.
- Splits node definition reloading into `app.reloadNodeDefs()` so
missing-model refresh can pull fresh `object_info` without showing the
generic combo refresh success flow.
- Reuses the existing missing model pipeline instead of adding a
separate candidate-only checker. The refresh path serializes the current
graph, reuses active workflow model metadata when available, falls back
to current missing-model metadata, and then reruns the same candidate
discovery/enrichment/surfacing flow used during workflow load.
- Adds missing model refresh state and error handling to
`missingModelStore`.
- Adds a Refresh button next to Download all in the missing model card
action bar.
- Moves Download all from the Errors tab header into the missing model
card, so the Download all and Refresh actions render or hide together.
- Changes Download all visibility from “more than one downloadable
model” to “at least one downloadable model.”
- Keeps the action bar hidden when there are no downloadable missing
models; Cloud still does not render this action area.
- Normalizes active workflow `pendingWarnings` updates so resolved
missing model warnings do not get revived by stale empty warning
objects.
- Adds test IDs and coverage for the new action bar, refresh state,
refresh delegation, pending warning sync, and E2E refresh behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The main design choice is intentionally reusing the missing model
pipeline for refresh instead of implementing a smaller candidate-only
recheck.

The earlier candidate-only approach was cheaper, but it created a
separate source of truth for missing-model resolution and made edge
cases harder to reason about. In particular, it could diverge from the
behavior used when a workflow is loaded, and it did not naturally handle
the case where a model becomes missing after the workflow is already
open. This version pays the cost of refreshing node definitions and
rerunning the missing-model scan for the current graph, but keeps the
refresh behavior aligned with workflow load semantics.

Expected behavior by environment:

- OSS browser:
- The action bar appears when at least one missing model has a
downloadable URL and directory.
  - Download all uses the existing browser download path.
- Refresh reloads `object_info`, refreshes node definitions/combo
values, reruns missing-model detection for the current graph, and clears
the error if the selected model is now available.
- OSS desktop:
- The same action bar appears under the same downloadable-model
condition.
  - Download all uses the existing Electron DownloadManager path.
- Refresh uses the same missing-model pipeline as browser, so manually
placed files or desktop-downloaded files can be rechecked without
reloading the workflow.
- Cloud:
- The action bar remains hidden because model download/import is not
supported in this section for Cloud.

A few boundaries are intentional:

- This PR does not add automatic filesystem watching. Browser OSS cannot
reliably observe local model folder changes, so the user-triggered
Refresh button remains the cross-environment mechanism.
- This PR does not redesign the public `refreshComboInNodes` API beyond
extracting `reloadNodeDefs()` for reuse. Further cleanup of toast
behavior or a more explicit object-info reload API can be follow-up
work.
- This PR keeps refresh scoped to missing-model validation; missing
media and missing nodes continue to use their existing flows.

Linear: FE-417

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2e02799f-1374-4377-b7b3-172241517772


## Validation

- `pnpm format`
- `pnpm lint` (passes; existing unrelated warning remains in
`src/platform/workspace/composables/useWorkspaceBilling.test.ts`)
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts`
- `pnpm build`
- `NX_SKIP_NX_CACHE=true DISTRIBUTION=desktop USE_PROD_CONFIG=true
NODE_OPTIONS='--max-old-space-size=8192' pnpm exec nx build`
- Manual desktop verification through `~/Projects/desktop` after copying
the desktop build into `assets/ComfyUI/web_custom_versions/desktop_app`:
  - confirmed the FE bundle is built with `DISTRIBUTION = "desktop"`
- confirmed missing model Download uses the desktop download path
instead of browser download
- confirmed Refresh can clear the missing model error after the model is
available
- Push hook: `pnpm knip --cache`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11661-feat-refresh-missing-models-through-pipeline-34f6d73d3650811488defee54a7a6667)
by [Unito](https://www.unito.io)
2026-04-27 18:53:50 +00:00
Christian Byrne
9a70676c61 [chore] Update Comfy Registry API types from comfy-api@c82adf6 (#11689)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: c82adf6
- Generated on: 2026-04-25T22:16:06Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11689-chore-Update-Comfy-Registry-API-types-from-comfy-api-c82adf6-34f6d73d3650815881e4dae4bdf05696)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-27 18:41:46 +00:00
Christian Byrne
9ce4c18eb1 test: add unit tests for useGLSLRenderer (#11390)
## Summary

Adds 44 unit tests for the `useGLSLRenderer` composable
(`src/renderer/glsl/useGLSLRenderer.ts`), increasing coverage from ~15%
to near-complete line coverage.

## Test Coverage

| Describe Block | Tests | What's Covered |
|---|---|---|
| `init` | 6 | Context creation, FBO setup, `UNPACK_FLIP_Y_WEBGL`, FBO
failure cleanup, post-dispose guard |
| `compileFragment` | 9 | Shader compile success/failure, program link
errors, program re-creation, dispose guard |
| `setResolution` | 4 | Viewport + FBO recreation on resize, no-op on
same size, dispose guard |
| `setFloatUniform` | 2 | Float uniform dispatch, dispose guard |
| `setIntUniform` | 2 | Int uniform dispatch, dispose guard |
| `bindInputImage` | 4 | Texture creation/binding, out-of-range index,
re-bind cleanup, dispose guard |
| `render` | 7 | Draw call, `u_resolution`, fallback textures, final
pass to default FBO, multi-pass ping-pong, dispose/no-program guards |
| `readPixels` | 2 | Returns `ImageData`, throws when uninitialized |
| `toBlob` | 2 | Returns `Blob`, throws when uninitialized |
| `dispose` | 4 | GL resource cleanup, idempotency, `onScopeDispose`,
input texture cleanup |
| `config` | 2 | Default vs custom uniform/input counts |

## Approach

- Mocks `WebGL2RenderingContext`, `OffscreenCanvas`, and `ImageData`
(happy-dom has no WebGL)
- Uses Vue `effectScope` to test `onScopeDispose` cleanup
- Mocks `detectPassCount` from `glslUtils` for multi-pass tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11390-test-add-unit-tests-for-useGLSLRenderer-3476d73d3650815a8787cd9368fd8baf)
by [Unito](https://www.unito.io)
2026-04-27 10:45:37 -04:00
Christian Byrne
56aec1878a test: add unit tests for GPUBrushRenderer (#11388)
## Summary

Add unit tests for `GPUBrushRenderer`, increasing coverage from ~3.5% to
cover constructor initialization, stroke rendering, compositing, preview
blitting, readback, and resource cleanup.

## Changes

- **What**: 27 unit tests for `GPUBrushRenderer` covering all public
methods with comprehensive WebGPU API mocks

## Review Focus

Mock factory approach for WebGPU objects — all GPU globals
(`GPUBufferUsage`, `GPUTextureUsage`, `GPUShaderStage`) are polyfilled
since happy-dom lacks WebGPU support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11388-test-add-unit-tests-for-GPUBrushRenderer-3476d73d3650814ab0e2c0fb8c424faa)
by [Unito](https://www.unito.io)
2026-04-27 10:34:33 -04:00
Dante
502a02213a test: add unit tests for useWorkspaceBilling polling and refresh paths (#11676)
## Summary

Extends `useWorkspaceBilling.test.ts` with five behavioral gaps in the
existing suite: `initialize` does not double-fetch balance when free
tier already has positive balance, `subscribe(planSlug)` forwards
`undefined` for return/cancel URLs, `previewSubscribe` does not refresh
status or balance after success, `pollCancelStatus` falls back to a
default error message when failed status omits `error_message`, and
`pollCancelStatus` halts further scheduled polls when a later poll's API
call rejects.

## Changes

- **What**: Adds 5 Vitest cases across four new `describe` blocks
(`initialize free-tier balance refresh`, `subscribe argument
forwarding`, `previewSubscribe does not refresh state`,
`pollCancelStatus error paths`). Reuses the existing `setupBilling()`
factory and `effectScope` lifecycle.

## Review Focus

- The mid-poll rejection test silences `unhandledRejection` for its
duration: `pollCancelStatus`'s rescheduled poll runs through `void
poll()` inside `setTimeout`, so the catch block's rethrow has no awaiter
and surfaces as unhandled. The test pins the observable behavior (no
further scheduled polls) without claiming `cancelSubscription`
propagates the late error.
- The `subscribe(planSlug)` test pins the call shape with explicit
`undefined` arguments so a future signature change breaks the test
rather than silently passing through.
- The free-tier branch is targeted: previously only the zero-balance
reload was tested; this adds the negative case (positive balance → no
second fetch).

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm format --
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

38 tests pass (33 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11676-test-add-unit-tests-for-useWorkspaceBilling-polling-and-refresh-paths-34f6d73d36508197bc7bc66d54e805e0)
by [Unito](https://www.unito.io)
2026-04-27 10:25:54 -04:00
Dante
f1ea3b02a6 test: add unit tests for useNodeReplacement transfer edge cases (#11677)
## Summary

Extends `useNodeReplacement.test.ts` with five connection-transfer and
graph-mutation edge cases that the existing 23-case suite did not cover:
missing-old-input-slot skip, missing-new-output-index resilience,
set_value on a non-existent widget, set_value with dot-notation new_id,
and the Vue-node refresh path via `nodeGraph.onNodeAdded`.

## Changes

- **What**: Adds 5 Vitest cases in a new `transfer edge cases` describe
block. Reuses the existing `createPlaceholderNode`, `createNewNode`,
`createMockGraph`, `createMockLink`, and `makeMissingNodeType` helpers —
no new test infrastructure introduced.

## Review Focus

- The "missing new output index" test verifies that `replaceWithMapping`
does not throw when `newNode.outputs[newOutputIdx]` is absent, and
asserts the original link's `origin_id` is unchanged so the silent-skip
behavior is pinned (not a swallowed exception).
- The dot-notation `set_value` test pins that the existing dot-notation
guard at `useNodeReplacement.ts:203` covers the `set_value` branch (not
just the `old_id` connection branch already covered at line 187).
- The `onNodeAdded` test asserts the Vue-node sync path that runs after
`replaceWithMapping` bypasses `graph.add()` — a future refactor that
drops the explicit call would silently break the Vue node renderer
otherwise.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm format -- src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

28 tests pass (23 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11677-test-add-unit-tests-for-useNodeReplacement-transfer-edge-cases-34f6d73d3650817aa2ffccdb9fb4a947)
by [Unito](https://www.unito.io)
2026-04-27 10:25:26 -04:00
Christian Byrne
b2bba78ce0 test: add unit tests for usePanAndZoom composable (#11391)
## Summary

Add 29 unit tests for the `usePanAndZoom` composable to improve mask
editor test coverage.

## Changes

- **What**: New test file
`src/composables/maskeditor/usePanAndZoom.test.ts` covering all public
API methods

## Review Focus

Test coverage spans:
- `initializeCanvasPanZoom` — landscape/portrait fit-to-view, panel
width accounting, style application
- `handlePanStart`/`handlePanMove` — panning state, offset updates,
error on missing start
- `zoom` — wheel in/out, clamping (0.2–10.0), missing canvas early
return, cursor update
- `updateCursorPosition` — pan offset application
- `invalidatePanZoom` — missing image warning, store container fallback,
rgbCanvas dimension sync
- Touch handlers — single/two-finger start, double-tap undo, pen
blocking, single-touch pan, pinch zoom, touch end states
- `addPenPointerId`/`removePenPointerId` — add, dedup, remove, no-op

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11391-test-add-unit-tests-for-usePanAndZoom-composable-3476d73d36508104b447d87471ce021b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-27 10:24:44 -04:00
Dante
3d14bfb09c fix: render asset fixtures in AssetBrowserModal stories (#11502)
## Why this fix exists

Surfaced as a dead-end during the FE-227 (asset modal scroll breakage in
cloud-prod) root-cause investigation, **not** as a standalone Storybook
complaint.

The natural local repro path for FE-227 is "bump an `AssetBrowserModal`
Storybook story to ~120 assets and watch the layout misbehave." When
that path was attempted, the stories rendered empty modals regardless of
fixture size. A Codex adversarial review confirmed the cause: three
stories bind `:assets="..."` to a prop the component never declared, so
the binding is silently dropped and the modal falls back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook.

The empty-modal failure mode also silently broke design QA / visual
review on this surface: any reviewer opening these stories has been
seeing "No assets found" for as long as the bug has existed.

Filing this as its own PR so:
- FE-227 stays focused on the cloud-prod scroll bug once a DevTools
datapoint confirms the hypothesis.
- The local repro path for FE-227 (and any future asset-modal layout
regression) becomes usable.
- Visual review on `AssetBrowserModal` is restored.

## What changed

Three `AssetBrowserModal` stories bound `:assets="..."` to a
non-existent prop, so the modal silently fell back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook because the model cache only initializes in cloud distribution
builds. Add an optional `assets` prop on `AssetBrowserModal` that, when
provided, bypasses the store fetch. Production callers continue to use
the store; this is a narrowly scoped Storybook/test seam.

- Fixes FE-232

### Why a prop on the component (Option 1) and not a Storybook decorator
(Option 2)

`assetsStore`'s model cache (`getModelState`) is gated by `if
(isCloud)`, returning an empty stub for desktop/localhost distributions.
Storybook's `.storybook/main.ts` does not define `__DISTRIBUTION__`, so
`isCloud === false` and the store has no public API to seed assets.
Public seed methods (`updateModelsForNodeType` / `updateModelsForTag`)
only delegate to `assetService` network calls. Option 2 (decorator-based
seeding) would require either patching Storybook's Vite `define` config
or building a parallel mock store via `resolve.alias` (the `useJobList`
precedent) — significantly more invasive than a +10 / -1 line component
change. The new prop is documented as a Storybook/test seam in JSDoc and
changes nothing for production callers.

### Bonus

`useAssetBrowserDialog.stories.ts:120` had the **same** broken
`:assets="mockAssets"` binding. The new prop transparently repairs it
without a separate change.

## Before

All three stories render an empty modal (`No assets found`) regardless
of the fixture data they pass.

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `before-default.png` → Default story
> - `before-single-asset-type.png` → Single asset type story
> - `before-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
| Default | |
<img width="961" height="821" alt="before-default"
src="https://github.com/user-attachments/assets/4a0af0f5-b712-41e2-adbc-c2b4b921045d"
/>

| Single asset type | |
<img width="961" height="821" alt="before-single-asset-type"
src="https://github.com/user-attachments/assets/073a8fa8-7bbb-4ec9-a226-156b7141d9b5"
/>

| No left panel | <img width="961" height="821"
alt="before-no-left-panel"
src="https://github.com/user-attachments/assets/0d45ff3b-5866-4de9-b7aa-5bd9cb1f3566"
/> |

## After

All three stories now render their intended fixture data (asset cards
visible with mock model names, badges, sort/filter controls populated).

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `after-default.png` → Default story
> - `after-single-asset-type.png` → Single asset type story
> - `after-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
<img width="961" height="821" alt="after-default"
src="https://github.com/user-attachments/assets/a11b2475-bd18-4c30-aece-cf1bdbcc6ac5"
<img width="961" height="821" alt="after-single-asset-type"
src="https://github.com/user-attachments/assets/71e11237-006b-43d9-90de-e9d2d8894e34"
/>
 />

| Default |  |
| Single asset type |   |
| No left panel | <img width="961" height="821"
alt="after-no-left-panel"
src="https://github.com/user-attachments/assets/5123db87-2ab9-4359-8e61-ac0d8da9494c"
/>
  |


## Test plan

- [x] Storybook stories now render fixture data (manually verified all
three via Chrome DevTools MCP)
- [x] `pnpm typecheck` passes on touched files
- [x] `pnpm lint` passes on touched files
- [x] Existing `AssetBrowserModal.test.ts` (14 tests) still passes
- [x] `useAssetBrowserDialog.stories.ts` is also functional (same bug
pattern, repaired by the new prop)
- [ ] No new prop surface added to `AssetBrowserModal` other than the
documented Storybook/test seam (`assets?: AssetItem[]`)
2026-04-27 11:30:59 +00:00
Dante
3340b77908 test: add unit tests for value-control widget family (#11440)
## Summary

Adds 42 unit tests across 5 files covering the value-control widget
family — first batch of a broader effort to raise widget-test coverage.

## Changes

- **What**:
- `WidgetInputNumber.test.ts` (9) — variant selection by widget.type
(int/float/slider/gradientslider), controlWidget wrapping in
WidgetWithControl, modelValue forwarding.
- `WidgetInputNumberGradientSlider.test.ts` (11) — initial value,
min/max/disabled pass-through, default vs custom gradient stops,
precision-derived step, WidgetLayoutField wrapping.
- `WidgetWithControl.test.ts` (5) — renders passed component with
widget/modelValue, initializes ValueControlButton mode from
widget.controlWidget.value, calls controlWidget.update on mode change.
- `ValueControlButton.test.ts` (11) — i18n aria-label per mode, text vs
icon rendering, pointer and keyboard activation, `type="button"` safety.
- `ValueControlPopover.test.ts` (6) — BEFORE/AFTER copy from
settingStore, four option render, v-model updates on selection.

## Review Focus

- Stack follows the existing widget-test pattern (`@testing-library/vue`
+ PrimeVue + `createI18n` where needed, no `@vue/test-utils`).
- `createMockWidget` from `widgetTestUtils.ts` reused; no new helper
extracted (YAGNI — per-file `renderComponent` stays ~10 lines).
- `WidgetWithControl` watcher test asserts first-arg of `update` since
the vue watch callback passes `(newVal, oldVal, onCleanup)`.
- No changes to any widget component source — tests-only PR.

This is one of several focused PRs in a widget-test-coverage sequence;
subsequent PRs cover form-dropdown internals, utility widgets,
media/graph/canvas widgets, and e2e value-type specs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11440-test-add-unit-tests-for-value-control-widget-family-3486d73d3650813891e1fe8d45eaecaf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-27 20:32:31 +09:00
Dante
0ad85087ea test: add unit tests for useMissingModelInteractions (#11675)
## Summary

Extends `useMissingModelInteractions.test.ts` with behavioral coverage
for the previously untested public surface: `getComboOptions` (both
asset-supported and widget-driven branches), `getDownloadStatus`, and
the four `handleImport` outcomes (async-pending, async-completed,
sync-with-mismatch, error).

## Changes

- **What**: Adds 10 Vitest cases across three new `describe` blocks
(`getComboOptions`, `getDownloadStatus`, `handleImport`). Extends the
existing module-level mocks with `mockUploadAssetAsync`,
`mockTrackDownload`, and `mockInvalidateModelsForCategory` so the import
flow can be verified at its boundaries.

## Review Focus

- The `handleImport` block uses a shared `setupImportableState(key)`
helper to seed `urlInputs` + `urlMetadata` and stub `validateSourceUrl`
once per test. Each case then asserts a single boundary effect (taskId
tracked, cache invalidated, mismatch recorded, error stored).
- The `getDownloadStatus` happy path relies on the existing getter-style
`mockDownloadList` so the test's mutation lands in the composable
without re-stubbing the asset download store.
- The `getComboOptions` "asset-supported" test asserts both the call
shape (`mockGetAssets` invoked with the candidate's `nodeType`) and the
output shape, so a future refactor that swaps the lookup key fails
loudly.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm format --
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

44 tests pass (34 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11675-test-add-unit-tests-for-useMissingModelInteractions-34f6d73d36508112909fe8e49cc68010)
by [Unito](https://www.unito.io)
2026-04-27 06:21:49 -04:00
pythongosssss
4c892341e4 feat: Node search UX updates (#9714)
## Summary

Addresses feedback from the initial v2 node search implementation for
improved UI and UX

## Changes

- **What**: 
- add root filter buttons
- remove all extra tree categories leaving only "Most relevant"
- replace input/output selection with popover
- replace price badge with one from node header
- add chevrons and additional styling to category tree
- hide empty categories
- fix bug with hovering selecting item under mouse automatically
- fix tailwind merge with custom sizes removing them
- keyboard navigation
- general tidy/refactor/test

## Screenshots (if applicable)

https://github.com/user-attachments/assets/db798dfa-e248-4b48-bb56-2fa7b6c5f65f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9714-feat-Node-search-UX-updates-31f6d73d365081cebd96c4253ad1ca53)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:47:47 +00:00
Christian Byrne
eb8f8b75b5 fix: correct zh-CN translations across landing, product, and pricing pages (#11655)
*PR Created by the Glary-Bot Agent*

---

## Summary

Applies translation corrections to the Chinese (zh-CN) version of the
website, requested in the `#project-new-website-brand-refresh` Slack
thread by a native-speaker reviewer.

### Changes (in `apps/website/src/i18n/translations.ts` +
`apps/website/src/pages/zh-CN/index.astro`)

- **Landing — blog section badges**: `如何` → `了解`, `运作` → `运行方式`
- **Landing — hero headline**: `专业控制` → `最强可控性` (also updates the zh-CN
page `<title>`)
- **Landing — hero subtitle**: `Comfy 是面向视觉专业人士的 AI
创作引擎,让您掌控每个模型、每个参数和每个输出。` → `Comfy 是面向专业视觉人士的 AI
创作引擎。您可以精确掌控每个模型、每个参数和每个输出。`
- **Landing — showcase subtitle**: `从社区模板开始,或从零构建。` → `从工作流模板开始,或从零构建。`
- **Landing — showcase feature1 title**: `节点式完全控制` → `节点带来的可控性`
- **Landing — use case body**: `由 60,000+ 节点、数千个工作流 和一个比任何公司都更快构建的社区驱动。`
→ `60,000+ 节点,数千条工作流,一个比任何公司速度都更快的社区。`
- **All `查看xx特性` CTAs** (local / cloud / API / enterprise product
cards): `特性` → `属性`
- **All `合作节点` pricing copy**: `合作节点` → `合作伙伴节点` (correct translation
for "partner node")

### Verification

- `pnpm typecheck` (astro check): 0 errors, 0 warnings, 0 hints
- `pnpm build`: completes successfully; rebuilt zh-CN pages contain the
new strings and no longer contain the old ones
- Manual Playwright verification on `/zh-CN/` (hero, showcase badges,
showcase feature card, use-case body, product cards) and
`/zh-CN/cloud/pricing/` (`合作伙伴节点` appears 4×, `合作节点` appears 0×)

### Notes on review feedback

A review pass flagged two of the swaps (`特性→属性` and `从社区模板→从工作流模板`) as
potential style concerns. Both changes are kept as-is because they were
specified verbatim by the native-speaker reviewer who requested this
pass.

## Screenshots

![zh-CN landing hero showing new headline and rewritten
subtitle](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526109-4a011f24-da46-4b6b-ba01-bce9cc8aec63.png)

![zh-CN showcase section showing updated badges, workflow template
subtitle, and node controllability feature
card](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526507-3054b3d7-0950-4c09-8edc-643c913ca1dc.png)

![zh-CN use-case section showing reworded 60,000+ node
copy](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526822-772cd907-0a70-441b-ac0c-bef2d1eb0f9f.png)

![zh-CN product cards showing updated
CTAs](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246527170-d1969308-5e6c-4d8f-bfee-24e31b0d0670.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11655-fix-correct-zh-CN-translations-across-landing-product-and-pricing-pages-34e6d73d3650816daddde4a90fb47a01)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 06:18:47 +00:00
316 changed files with 19925 additions and 2707 deletions

View File

@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -82,7 +82,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -140,7 +140,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
on:
push:
branches: [main, website/*]
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
@@ -17,7 +17,7 @@ on:
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-red-600);
background-color: var(--color-coral-700);
}
.p-button-danger:hover {
background-color: var(--color-coral-red-500);
background-color: var(--color-coral-600);
}
.p-button-danger:active {
background-color: var(--color-coral-red-400);
background-color: var(--color-coral-500);
}
.task-div .p-card {

View File

@@ -4,12 +4,12 @@ const translations = {
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
'zh-CN': '视觉 AI 的\n专业控制'
'zh-CN': '视觉 AI 的\n最强可控性'
},
'hero.subtitle': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
},
// ProductShowcaseSection
@@ -20,11 +20,11 @@ const translations = {
},
'showcase.subtitle2': {
en: 'Start from a community template or build from scratch.',
'zh-CN': '从社区模板开始,或从零构建。'
'zh-CN': '从工作流模板开始,或从零构建。'
},
'showcase.feature1.title': {
en: 'Full Control with Nodes',
'zh-CN': '节点式完全控制'
'zh-CN': '节点带来的可控性'
},
'showcase.feature1.description': {
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
@@ -49,8 +49,8 @@ const translations = {
'zh-CN':
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
},
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运' },
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
// UseCaseSection
'useCase.label': {
@@ -83,8 +83,7 @@ const translations = {
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
'zh-CN':
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
},
'useCase.cta': {
en: 'EXPLORE WORKFLOWS',
@@ -164,7 +163,7 @@ const translations = {
},
'products.local.cta': {
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版性'
'zh-CN': '查看本地版性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -176,7 +175,7 @@ const translations = {
},
'products.cloud.cta': {
en: 'SEE CLOUD FEATURES',
'zh-CN': '查看云端性'
'zh-CN': '查看云端性'
},
'products.api.title': {
en: 'Comfy\nAPI',
@@ -188,7 +187,7 @@ const translations = {
},
'products.api.cta': {
en: 'SEE API FEATURES',
'zh-CN': '查看 API 性'
'zh-CN': '查看 API 性'
},
'products.enterprise.title': {
en: 'Comfy\nEnterprise',
@@ -200,7 +199,7 @@ const translations = {
},
'products.enterprise.cta': {
en: 'SEE ENTERPRISE FEATURES',
'zh-CN': '查看企业版性'
'zh-CN': '查看企业版性'
},
// CaseStudySpotlightSection
@@ -1215,7 +1214,7 @@ const translations = {
'pricing.included.feature4.description': {
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
'zh-CN':
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro。'
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro。'
},
'pricing.included.feature5.title': {
en: 'Add more credits anytime',
@@ -1245,12 +1244,12 @@ const translations = {
},
'pricing.included.feature8.title': {
en: 'Partner Nodes',
'zh-CN': '合作节点'
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',

View File

@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ProductShowcaseSection locale="zh-CN" client:load />

View File

@@ -15,11 +15,15 @@ browser_tests/
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
│ ├── components/ - Page object components (locators, user interactions)
│ ├── components/ - Page object classes (locators, user interactions)
│ │ ├── Actionbar.ts
│ │ ├── ContextMenu.ts
│ │ ├── ManageGroupNode.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ ── Topbar.ts
│ │ ── Templates.ts
│ │ ├── Topbar.ts
│ │ └── ...
│ ├── helpers/ - Focused helper classes (domain-specific actions)
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
@@ -28,17 +32,36 @@ browser_tests/
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Pure utility functions (no page dependency)
├── helpers/ - Test-specific utilities
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
│ ├── builderTestUtils.ts
│ ├── clipboardSpy.ts
│ ├── fitToView.ts
│ ├── perfReporter.ts
│ └── ...
└── tests/ - Test files (*.spec.ts)
```
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
### Placement Rule
When adding a new file, use this decision tree:
```mermaid
flowchart TD
A[New file in browser_tests/fixtures/] --> B{Has any code?}
B -- No, JSON/data only --> D[fixtures/data/]
B -- Yes --> C{Is it a class?}
C -- No, exported functions --> U[fixtures/utils/]
C -- Yes --> E{Owns locators for a<br/>specific UI region?}
E -- Yes --> P[fixtures/components/]
E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/]
```
## Page Object Locator Style

View File

@@ -51,6 +51,7 @@ DISABLE_VUE_PLUGINS=true
# Test against dev server (recommended) or backend directly
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
# Path to ComfyUI for backing up user data/settings before tests
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
@@ -139,12 +140,9 @@ Always check for existing helpers and fixtures before implementing new ones:
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
- **ComfyMouse**: Helper for precise mouse operations on the canvas
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
- `actionbar.ts`: Interact with the action bar
- `manageGroupNode.ts`: Group node management operations
- `templates.ts`: Template workflows operations
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`)
- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`)
- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`)
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.

View File

@@ -0,0 +1,74 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [50, 50],
"size": [400, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 1,
"type": "Painter",
"pos": [450, 50],
"size": [450, 550],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/helpers/actionbar'
import { ComfyTemplates } from '@e2e/helpers/templates'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
@@ -22,6 +22,7 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import {
AssetsSidebarTab,
ModelLibrarySidebarTab,
@@ -54,11 +55,13 @@ class ComfyPropertiesPanel {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
}
}
@@ -137,6 +140,7 @@ class ComfyMenu {
export class ComfyPage {
public readonly url: string
public readonly apiUrl: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
public readonly selectionToolbox: Locator
@@ -159,6 +163,7 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly templatesDialog: TemplatesDialog
public readonly titleEditor: TitleEditor
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
@@ -195,6 +200,7 @@ export class ComfyPage {
public readonly request: APIRequestContext
) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
this.canvas = page.locator('#graph-canvas')
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
@@ -204,13 +210,14 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.templatesDialog = new TemplatesDialog(page)
this.titleEditor = new TitleEditor(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
@@ -236,7 +243,7 @@ export class ComfyPage {
}
async setupUser(username: string) {
const res = await this.request.get(`${this.url}/api/users`)
const res = await this.request.get(`${this.apiUrl}/api/users`)
if (res.status() !== 200)
throw new Error(`Failed to retrieve users: ${await res.text()}`)
@@ -250,7 +257,7 @@ export class ComfyPage {
}
async createUser(username: string) {
const resp = await this.request.post(`${this.url}/api/users`, {
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
data: { username }
})
@@ -262,7 +269,7 @@ export class ComfyPage {
async setupSettings(settings: Record<string, unknown>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
`${this.apiUrl}/api/devtools/set_settings`,
{
data: settings
}

View File

@@ -1,29 +1,104 @@
import type { Locator, Page } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
const { searchBoxV2 } = TestIds
export type { RootCategoryId }
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator
readonly filterChips: Locator
readonly noResults: Locator
readonly nodeIdBadge: Locator
constructor(readonly page: Page) {
constructor(private comfyPage: ComfyPage) {
const page = comfyPage.page
this.dialog = page.getByRole('search')
this.input = this.dialog.locator('input[type="text"]')
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId(searchBoxV2.resultItem)
this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption)
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
}
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
categoryButton(categoryId: string): Locator {
return this.dialog.getByTestId(`category-${categoryId}`)
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
}
filterBarButton(name: string): Locator {
return this.dialog.getByRole('button', { name })
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
rootCategoryButton(id: RootCategoryId): Locator {
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
}
async reload(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
/** Top filter-bar input/output type popover trigger. */
typeFilterButton(key: 'input' | 'output'): Locator {
return this.dialog.getByTestId(searchBoxV2.typeFilter(key))
}
async applyTypeFilter(
key: 'input' | 'output',
typeName: string
): Promise<void> {
const trigger = this.typeFilterButton(key)
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'visible' })
await this.filterSearch.fill(typeName)
await this.filterOptions.filter({ hasText: typeName }).first().click()
// The popover does not auto-close on selection — toggle the trigger.
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'hidden' })
}
async removeFilterChip(index = 0): Promise<void> {
await this.filterChips
.nth(index)
.getByTestId(searchBoxV2.chipDelete)
.click()
}
async toggle(): Promise<void> {
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
}
async open(): Promise<void> {
if (await this.input.isVisible()) return
await this.toggle()
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(): Promise<void> {
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
await this.comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
}
async setup(): Promise<void> {
await this.ensureV2Search()
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
}
}

View File

@@ -4,11 +4,13 @@ import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly menuItems: Locator
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
}
@@ -39,7 +41,10 @@ export class ContextMenu {
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible
const litegraphContextVisible = await this.litegraphContextMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible || litegraphContextVisible
}
async assertHasItems(items: string[]): Promise<void> {
@@ -71,7 +76,8 @@ export class ContextMenu {
async waitForHidden(): Promise<void> {
await Promise.all([
this.primeVueMenu.waitFor({ state: 'hidden' }),
this.litegraphMenu.waitFor({ state: 'hidden' })
this.litegraphMenu.waitFor({ state: 'hidden' }),
this.litegraphContextMenu.waitFor({ state: 'hidden' })
])
}
}

View File

@@ -0,0 +1,72 @@
import type { Locator, Page } from '@playwright/test'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
import { TestIds } from '@e2e/fixtures/selectors'
export class PublishDialog extends BaseDialog {
readonly nav: Locator
readonly footer: Locator
readonly savePrompt: Locator
readonly describeStep: Locator
readonly finishStep: Locator
readonly profilePrompt: Locator
readonly gateFlow: Locator
readonly nameInput: Locator
readonly descriptionTextarea: Locator
readonly tagsInput: Locator
readonly backButton: Locator
readonly nextButton: Locator
readonly publishButton: Locator
constructor(page: Page) {
super(page, TestIds.publish.dialog)
this.nav = this.root.getByTestId(TestIds.publish.nav)
this.footer = this.root.getByTestId(TestIds.publish.footer)
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
this.descriptionTextarea = this.describeStep.locator('textarea')
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
this.backButton = this.footer.getByRole('button', { name: 'Back' })
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
this.publishButton = this.footer.getByRole('button', {
name: 'Publish to ComfyHub'
})
}
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
// dev and production, rather than clicking through the UI.
async open(): Promise<void> {
await this.page.evaluate(async () => {
await window.app!.extensionManager.dialog.showPublishDialog()
})
await this.waitForVisible()
}
tagSuggestion(name: string): Locator {
return this.describeStep.getByText(name, { exact: true })
}
navStep(label: string): Locator {
return this.nav.getByRole('button', { name: label })
}
currentNavStep(): Locator {
return this.nav.locator('[aria-current="step"]')
}
async goNext(): Promise<void> {
await this.nextButton.click()
}
async goBack(): Promise<void> {
await this.backButton.click()
}
async goToStep(label: string): Promise<void> {
await this.navStep(label).click()
}
}

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The node/group title-editing input. Rendered in three scopes: the canvas
* overlay (page-wide), the properties panel, and the Vue node itself.
*/
export class TitleEditor {
public readonly input: Locator
constructor(scope: Page | Locator) {
this.input = scope.getByTestId(TestIds.node.titleInput)
}
async setTitle(title: string): Promise<void> {
await this.input.fill(title)
await this.input.press('Enter')
}
async cancel(): Promise<void> {
await this.input.press('Escape')
}
async expectVisible(): Promise<void> {
await expect(this.input).toBeVisible()
}
async expectHidden(): Promise<void> {
await expect(this.input).toBeHidden()
}
}

View File

@@ -1,9 +1,19 @@
import type { Page, Route } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type {
CreateAssetExportData,
CreateAssetExportResponse,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobsListRoutePattern = '**/api/jobs?*'
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
const assetExportRoutePattern = '**/api/assets/export'
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
@@ -158,12 +168,23 @@ function getExecutionDuration(job: RawJobListItem): number {
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
null
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private cloudAssetsResponse: ListAssetsResponse | null = null
private assetExportRequests: CreateAssetExportData['body'][] = []
private assetExportResponse: CreateAssetExportResponse | null = null
private importedFiles: string[] = []
private readonly jobDetailRouteHandlers = new Map<
string,
(route: Route) => Promise<void>
>()
constructor(private readonly page: Page) {}
@@ -240,6 +261,82 @@ export class AssetsHelper {
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
this.cloudAssetsResponse = response
if (this.cloudAssetsRouteHandler) {
return
}
this.cloudAssetsRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.cloudAssetsResponse)
})
}
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
}
async mockEmptyCloudAssets(): Promise<void> {
await this.mockCloudAssets({
assets: [],
total: 0,
has_more: false
})
}
async captureAssetExportRequests(
response: CreateAssetExportResponse = {
task_id: 'asset-export-task',
status: 'created'
}
): Promise<CreateAssetExportData['body'][]> {
this.assetExportRequests = []
this.assetExportResponse = response
if (this.assetExportRouteHandler) {
return this.assetExportRequests
}
this.assetExportRouteHandler = async (route: Route) => {
this.assetExportRequests.push(
route.request().postDataJSON() as CreateAssetExportData['body']
)
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify(this.assetExportResponse)
})
}
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
return this.assetExportRequests
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
if (existingHandler) {
await this.page.unroute(pattern, existingHandler)
}
const handler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(detail)
})
}
this.jobDetailRouteHandlers.set(pattern, handler)
await this.page.route(pattern, handler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
@@ -295,6 +392,9 @@ export class AssetsHelper {
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.cloudAssetsResponse = null
this.assetExportRequests = []
this.assetExportResponse = null
this.importedFiles = []
if (this.jobsRouteHandler) {
@@ -302,6 +402,22 @@ export class AssetsHelper {
this.jobsRouteHandler = null
}
if (this.cloudAssetsRouteHandler) {
await this.page.unroute(
assetsListRoutePattern,
this.cloudAssetsRouteHandler
)
this.cloudAssetsRouteHandler = null
}
if (this.assetExportRouteHandler) {
await this.page.unroute(
assetExportRoutePattern,
this.assetExportRouteHandler
)
this.assetExportRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
@@ -317,5 +433,10 @@ export class AssetsHelper {
)
this.deleteHistoryRouteHandler = null
}
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
await this.page.unroute(pattern, handler)
}
this.jobDetailRouteHandlers.clear()
}
}

View File

@@ -74,7 +74,7 @@ export class CanvasHelper {
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
private async toAbsolute(position: Position): Promise<Position> {
async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
@@ -150,6 +150,28 @@ export class CanvasHelper {
await nextFrame(this.page)
}
async getOffset(): Promise<[number, number]> {
return this.page.evaluate(
() => [...window.app!.canvas.ds.offset] as [number, number]
)
}
async getNodeTitleHeight(): Promise<number> {
return this.page.evaluate(() => window.LiteGraph!.NODE_TITLE_HEIGHT)
}
/**
* Hold `Control+Shift` and drag from `from` to `to` using page-absolute
* coordinates.
*/
async ctrlShiftDrag(from: Position, to: Position): Promise<void> {
await this.page.keyboard.down('Control')
await this.page.keyboard.down('Shift')
await this.dragAndDrop(from, to)
await this.page.keyboard.up('Shift')
await this.page.keyboard.up('Control')
}
async convertOffsetToCanvas(
pos: [number, number]
): Promise<[number, number]> {
@@ -242,11 +264,39 @@ export class CanvasHelper {
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
async disconnectEdge(
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
): Promise<void> {
const { modifiers = [] } = options
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
}
async middleClick(position: Position): Promise<void> {
await this.mouseClickAt(position, { button: 'middle' })
}
async dblclickGroupTitle(title: string): Promise<void> {
const clientPos = await this.page.evaluate((targetTitle) => {
const groups = window.app!.canvas.graph?.groups ?? []
const group = groups.find(
(g: { title: string }) => g.title === targetTitle
)
if (!group) return null
const cx = group.pos[0] + group.size[0] / 2
const cy = group.pos[1] + group.titleHeight / 2
return window.app!.canvasPosToClientPos([cx, cy])
}, title)
if (!clientPos) throw new Error(`Group "${title}" not found`)
await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 })
await nextFrame(this.page)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {

View File

@@ -4,7 +4,7 @@ import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
export class ClipboardHelper {
constructor(

View File

@@ -13,7 +13,11 @@ import type { Page } from '@playwright/test'
* so the SDK believes a user is signed in. Must be called before navigation.
*/
export class CloudAuthHelper {
constructor(private readonly page: Page) {}
private readonly appUrl: string
constructor(private readonly page: Page) {
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
}
/**
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
@@ -34,7 +38,7 @@ export class CloudAuthHelper {
*/
private async seedFirebaseIndexedDB(): Promise<void> {
// Navigate to a lightweight endpoint to get a same-origin context
await this.page.goto('http://localhost:8188/api/users')
await this.page.goto(`${this.appUrl}/api/users`)
await this.page.evaluate(() => {
const MOCK_USER_DATA = {

View File

@@ -3,7 +3,7 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'

View File

@@ -1,6 +1,10 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
@@ -39,6 +43,48 @@ export class NodeOperationsHelper {
})
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and position is provided, a synthetic MouseEvent is created as the
* dragEvent.
* @param position - When ghost is true, client coordinates for the ghost
* placement dragEvent. Otherwise, world coordinates assigned to node.pos.
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
position?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, pos]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && pos) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: pos.x,
clientY: pos.y
})
} else if (pos) {
node.pos = [pos.x, pos.y]
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, position ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Remove all nodes from the graph and clean. */
async clearGraph() {
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)

View File

@@ -0,0 +1,231 @@
import type { Page, Route } from '@playwright/test'
import type {
AssetInfo,
HubAssetUploadUrlResponse,
HubLabelInfo,
HubLabelListResponse,
HubProfile,
WorkflowPublishInfo
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
const DEFAULT_PROFILE: HubProfile = {
username: 'testuser',
display_name: 'Test User',
description: 'A test creator',
avatar_url: undefined
}
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
{ name: 'anime', display_name: 'anime', type: 'tag' },
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
]
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
workflow_id: 'test-workflow-id-456',
share_id: 'test-share-id-123',
publish_time: new Date().toISOString(),
listed: true,
assets: []
}
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
upload_url: 'https://mock-s3.example.com/upload',
public_url: 'https://mock-s3.example.com/asset.png',
token: 'mock-upload-token'
}
export class PublishApiHelper {
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(private readonly page: Page) {}
async mockProfile(profile: HubProfile | null): Promise<void> {
await this.addRoute('**/hub/profiles/me', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (profile === null) {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(profile)
})
}
})
}
async mockTagLabels(
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
): Promise<void> {
const response: HubLabelListResponse = { labels }
await this.addRoute('**/hub/labels**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishStatus(
status: 'unpublished' | WorkflowPublishInfo
): Promise<void> {
await this.addRoute('**/userdata/*/publish', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (status === 'unpublished') {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(status)
})
}
})
}
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
const response: ShareableAssetsResponse = { assets }
await this.addRoute('**/assets/from-workflow', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflow(
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflowError(
statusCode = 500,
message = 'Failed to publish workflow'
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: statusCode,
contentType: 'application/json',
body: JSON.stringify({ message })
})
})
}
async mockUploadUrl(
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
): Promise<void> {
await this.addRoute('**/hub/assets/upload-url', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async setupDefaultMocks(options?: {
hasProfile?: boolean
hasPrivateAssets?: boolean
}): Promise<void> {
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
await this.mockTagLabels()
await this.mockPublishStatus('unpublished')
await this.mockShareableAssets(
hasPrivateAssets
? [
{
id: 'asset-1',
name: 'my_model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: true
}
]
: []
)
await this.mockPublishWorkflow()
await this.mockUploadUrl()
}
async cleanup(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
}
private async addRoute(
pattern: string,
handler: (route: Route) => Promise<void>
): Promise<void> {
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
private async removeRoutes(pattern: string): Promise<void> {
const handlers = this.routeHandlers.filter(
(route) => route.pattern === pattern
)
for (const { handler } of handlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = this.routeHandlers.filter(
(route) => route.pattern !== pattern
)
}
}
export const publishFixture = comfyPageFixture.extend<{
publishApi: PublishApiHelper
publishDialog: PublishDialog
}>({
publishApi: async ({ comfyPage }, use) => {
const helper = new PublishApiHelper(comfyPage.page)
await use(helper)
await helper.cleanup()
},
publishDialog: async ({ comfyPage }, use) => {
await use(new PublishDialog(comfyPage.page))
}
})

View File

@@ -59,6 +59,9 @@ export const TestIds = {
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
@@ -83,7 +86,11 @@ export const TestIds = {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button'
subscribeButton: 'topbar-subscribe-button',
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -209,6 +216,18 @@ export const TestIds = {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
},
publish: {
dialog: 'publish-dialog',
savePrompt: 'publish-save-prompt',
describeStep: 'publish-describe-step',
finishStep: 'publish-finish-step',
footer: 'publish-footer',
profilePrompt: 'publish-profile-prompt',
nav: 'publish-nav',
gateFlow: 'publish-gate-flow',
nameInput: 'publish-name-input',
tagsInput: 'publish-tags-input'
},
loading: {
overlay: 'loading-overlay'
},
@@ -234,6 +253,17 @@ export const TestIds = {
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
},
searchBoxV2: {
resultItem: 'result-item',
filterOption: 'filter-option',
filterChip: 'filter-chip',
chipDelete: 'chip-delete',
noResults: 'no-results',
nodeIdBadge: 'node-id-badge',
category: (id: string) => `category-${id}`,
rootCategory: (id: string) => `search-category-${id}`,
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
}
} as const

View File

@@ -5,7 +5,7 @@ import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { comfyExpect } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
interface BuilderSetupResult {
inputNodeTitle: string

View File

@@ -1,7 +1,8 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
@@ -169,6 +170,36 @@ class NodeSlotReference {
[this.type, this.node.id, this.index] as const
)
}
async getLink(): Promise<SerialisableLLink | null> {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const graph = window.app!.canvas.graph!
const node = graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const linkId =
type === 'input'
? node.inputs[index].link
: (node.outputs[index].links ?? [])[0]
if (linkId == null) return null
const link =
graph.links instanceof Map
? graph.links.get(linkId)
: graph.links[linkId]
if (!link) return null
return {
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
target_id: link.target_id,
target_slot: link.target_slot,
type: link.type,
parentId: link.parentId
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
@@ -326,6 +357,23 @@ export class NodeReference {
const nodeSize = await this.getSize()
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
}
async dragBy(
delta: Position,
options?: {
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const titlePos = await this.getTitlePosition()
const target = { x: titlePos.x + delta.x, y: titlePos.y + delta.y }
const modifiers = options?.modifiers ?? []
const keyboard = this.comfyPage.page.keyboard
for (const mod of modifiers) await keyboard.down(mod)
try {
await this.comfyPage.canvasOps.dragAndDrop(titlePos, target)
} finally {
for (const mod of modifiers) await keyboard.up(mod)
}
}
async isPinned() {
return !!(await this.getFlags()).pinned
}

View File

@@ -56,7 +56,13 @@ export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let entries
try {
entries = readdirSync('test-results', { withFileTypes: true })
} catch {
return
}
if (!entries.length) return
let tempFiles: string[]
try {

View File

@@ -1,6 +1,6 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
type PromotedWidgetEntry = [string, string]
export type PromotedWidgetEntry = [string, string]
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (

View File

@@ -1,12 +1,13 @@
import type { Locator } from '@playwright/test'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
public readonly title: Locator
public readonly titleInput: Locator
public readonly titleEditor: TitleEditor
public readonly body: Locator
public readonly pinIndicator: Locator
public readonly collapseButton: Locator
@@ -16,7 +17,7 @@ export class VueNodeFixture {
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
this.title = locator.getByTestId('node-title')
this.titleInput = locator.getByTestId('node-title-input')
this.titleEditor = new TitleEditor(locator)
this.body = locator.locator('[data-testid^="node-body-"]')
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
this.collapseButton = locator.getByTestId('node-collapse-button')
@@ -30,17 +31,8 @@ export class VueNodeFixture {
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.fill(value)
await input.press('Enter')
}
async cancelTitleEdit(): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.press('Escape')
await this.titleEditor.expectVisible()
await this.titleEditor.setTitle(value)
}
async toggleCollapse(): Promise<void> {

View File

@@ -2,7 +2,7 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR, coverageSourceFilter } from '@e2e/coverageConfig'
import { writePerfReport } from '@e2e/helpers/perfReporter'
import { writePerfReport } from '@e2e/fixtures/utils/perfReporter'
import { restorePath } from '@e2e/utils/backupUtils'
dotenvConfig()

View File

@@ -0,0 +1,140 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
const ICON_CLASS = 'icon-[lucide--star]'
const BUTTON_LABEL = 'Test Action'
const BUTTON_TOOLTIP = 'Test action tooltip'
async function registerTestButton(
page: Page,
opts: {
name?: string
icon?: string
label?: string
tooltip?: string
} = {}
): Promise<void> {
await page.evaluate(
({ name, icon, label, tooltip }) => {
window.app!.registerExtension({
name,
actionBarButtons: [{ icon, label, tooltip, onClick: () => {} }]
})
},
{
name: opts.name ?? 'TestActionBarButton',
icon: opts.icon ?? ICON_CLASS,
label: opts.label ?? BUTTON_LABEL,
tooltip: opts.tooltip ?? BUTTON_TOOLTIP
}
)
}
test.describe('ActionBar Buttons', { tag: ['@ui'] }, () => {
test.describe('Empty state', () => {
test('container is hidden when no extension registers buttons', async ({
comfyPage
}) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.actionBarButtons)
).toBeHidden()
})
})
test.describe('Button rendering', () => {
test('registered button is visible with correct label', async ({
comfyPage
}) => {
await registerTestButton(comfyPage.page)
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(container).toBeVisible()
await expect(
container.getByRole('button', { name: BUTTON_TOOLTIP })
).toBeVisible()
await expect(container.getByText(BUTTON_LABEL)).toBeVisible()
})
test('button icon is rendered', async ({ comfyPage }) => {
await registerTestButton(comfyPage.page)
const icon = comfyPage.page
.getByTestId(TestIds.topbar.actionBarButtons)
.getByRole('button', { name: BUTTON_TOOLTIP })
.locator('i')
await expect(icon).toHaveClass(ICON_CLASS)
})
test('multiple registered buttons all appear', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestActionBarButtons',
actionBarButtons: [
{
icon: 'icon-[lucide--star]',
label: 'First',
tooltip: 'First action',
onClick: () => {}
},
{
icon: 'icon-[lucide--heart]',
label: 'Second',
tooltip: 'Second action',
onClick: () => {}
}
]
})
})
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(
container.getByRole('button', { name: 'First action' })
).toBeVisible()
await expect(
container.getByRole('button', { name: 'Second action' })
).toBeVisible()
})
})
test.describe('Click handler', () => {
test('clicking a button fires its onClick handler', async ({
comfyPage
}) => {
const onClickFired = comfyPage.page.evaluate(
({ icon, label, tooltip }) =>
new Promise<boolean>((resolve) => {
window.app!.registerExtension({
name: 'TestActionBarButton',
actionBarButtons: [
{ icon, label, tooltip, onClick: () => resolve(true) }
]
})
}),
{ icon: ICON_CLASS, label: BUTTON_LABEL, tooltip: BUTTON_TOOLTIP }
)
const button = comfyPage.page
.getByTestId(TestIds.topbar.actionBarButtons)
.getByRole('button', { name: BUTTON_TOOLTIP })
await button.click()
await expect(onClickFired).resolves.toBe(true)
})
})
test.describe('Mobile layout', { tag: ['@mobile'] }, () => {
test('button label is hidden on mobile viewport', async ({ comfyPage }) => {
await registerTestButton(comfyPage.page)
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(container).toBeVisible()
await expect(container.getByText(BUTTON_LABEL)).toBeHidden()
})
})
})

View File

@@ -2,7 +2,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
test.describe('App mode arrange step', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -3,8 +3,8 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
const RESIZE_NODE_ID = '1'

View File

@@ -5,7 +5,7 @@ import {
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '@e2e/helpers/builderTestUtils'
} from '@e2e/fixtures/utils/builderTestUtils'
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -12,7 +12,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
import {
getClipboardText,
interceptClipboardWrite
} from '@e2e/helpers/clipboardSpy'
} from '@e2e/fixtures/utils/clipboardSpy'
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)

View File

@@ -8,7 +8,7 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/helpers/builderTestUtils'
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']

View File

@@ -8,8 +8,8 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
} from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
/**
* After a first save, open save-as again from the chevron,

View File

@@ -0,0 +1,175 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
const expectedGroupSize = (
nodeBounds: Size,
padding: number,
titleHeight: number
): Size => ({
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
})
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
const DRAG_DELTA = { x: 550, y: 330 } as const
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
const createNode = async (comfyPage: ComfyPage) => {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
})
await note.centerOnNode()
return note
}
test('shift+drag rounds final node position to multiples of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 100 = (600, 300)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(600)
expect(after[1]).toBe(300)
})
test('grid size determines the snap multiple', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 50)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 50 = (550, 350)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(550)
expect(after[1]).toBe(350)
})
test('drag without shift bypasses snap regardless of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
const before = await note.getProperty<[number, number]>('pos')
await note.dragBy(DRAG_DELTA)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(before[0] + DRAG_DELTA.x)
expect(after[1]).toBe(before[1] + DRAG_DELTA.y)
})
})
test.describe('Comfy.GroupSelectedNodes.Padding', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
const groupAroundAllNodesWithPadding = async (
comfyPage: ComfyPage,
padding: number
): Promise<Size> => {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
)
await comfyPage.command.executeCommand('Comfy.Canvas.SelectAll')
await comfyPage.command.executeCommand('Comfy.Graph.GroupSelectedNodes')
return comfyPage.page.evaluate(() => {
const group = window.app!.graph.groups[0]
return { width: group.size[0], height: group.size[1] }
})
}
test('padding=0 makes the group exactly enclose the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 0)
expect(group).toEqual(expectedGroupSize(nodeBounds, 0, titleHeight))
})
test('padding=50 grows the group by 100 around the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 50)
expect(group).toEqual(expectedGroupSize(nodeBounds, 50, titleHeight))
})
})
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
const openComboMenu = async (comfyPage: ComfyPage) => {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const fileCombo = await loadImage.getWidget(0)
await fileCombo.click()
}
test('combo widget popup is scaled when setting is enabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('LiteGraph.ContextMenu.Scaling', true)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS(
'transform',
`matrix(${ZOOM_SCALE}, 0, 0, ${ZOOM_SCALE}, 0, 0)`
)
})
test('combo widget popup is not scaled when setting is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.ContextMenu.Scaling',
false
)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS('transform', 'none')
})
})
})

View File

@@ -0,0 +1,400 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow is expected to contain exactly two CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const p1 = await clipNodes[0].getPosition()
const p2 = await clipNodes[1].getPosition()
const margin = 64
const from = await comfyPage.canvasOps.toAbsolute({
x: Math.min(p1.x, p2.x) - margin,
y: Math.min(p1.y, p2.y) - margin
})
const to = await comfyPage.canvasOps.toAbsolute({
x: Math.max(p1.x, p2.x) + margin,
y: Math.max(p1.y, p2.y) + margin
})
return { from, to }
}
test.describe('Canvas settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Graph.CanvasInfo', () => {
test(
'toggles the bottom-left HUD',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const box = await comfyPage.canvas.boundingBox()
expect(box, 'Canvas bounding box must be available').not.toBeNull()
// HUD is drawn ~80px tall along the bottom edge of the canvas; grab a
// comfortable 180px × 160px strip to catch it across viewports.
const HUD_WIDTH = 180
const HUD_HEIGHT = 160
const hudClip = {
x: box!.x,
y: box!.y + box!.height - HUD_HEIGHT,
width: HUD_WIDTH,
height: HUD_HEIGHT
}
await test.step('Capture HUD region with setting off', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
await comfyPage.canvasOps.resetView()
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
await test.step('Capture HUD region with setting on', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
}
)
})
test.describe('Comfy.Graph.CtrlShiftZoom', () => {
const CTRL_SHIFT_DRAG_FROM = { x: 100, y: 100 }
const CTRL_SHIFT_DRAG_TO = { x: 400, y: 400 }
test('Ctrl+Shift+drag zooms canvas when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', true)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.not.toBeCloseTo(initialScale, 2)
})
test('Ctrl+Shift+drag does not zoom when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', false)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 2)
})
})
test.describe('Comfy.Graph.LiveSelection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)
})
test('selects nodes mid-drag when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', true)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
})
test('defers selection to drag end when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', false)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
await comfyPage.page.mouse.up()
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Comfy.Canvas.MouseWheelScroll', () => {
const WHEEL_POS = { x: 400, y: 400 }
test('wheel zooms when set to zoom', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'zoom'
)
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).not.toBeCloseTo(
initialScale,
3
)
})
test('wheel pans when set to panning', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'panning'
)
const initialScale = await comfyPage.canvasOps.getScale()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 3)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(1)
})
})
test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
test('override to panning makes empty left-drag pan the canvas', async ({
comfyPage
}) => {
await test.step("Flip to 'select' then back to 'panning' (NavigationMode→custom)", async () => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'panning'
)
})
await comfyPage.canvasOps.resetView()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.canvasOps.dragAndDrop(
{ x: 200, y: 300 },
{ x: 400, y: 500 }
)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(50)
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
})
test('override to select turns empty left-drag into a selection rectangle', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.canvasOps.dragAndDrop(from, to)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Pointer settings', () => {
/**
* Press left-mouse at canvas-relative `pos`, hold for `holdMs` (0 = no
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
* because it exists only to probe the CanvasPointer timing thresholds.
*/
const holdDragAt = async (
comfyPage: ComfyPage,
pos: { x: number; y: number },
opts: { dx: number; dy: number; holdMs: number }
) => {
const abs = await comfyPage.canvasOps.toAbsolute(pos)
await comfyPage.page.mouse.move(abs.x, abs.y)
await comfyPage.page.mouse.down()
await sleep(opts.holdMs)
await comfyPage.page.mouse.move(abs.x + opts.dx, abs.y + opts.dy)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
}
test('DoubleClickTime controls whether two clicks open the title editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
true
)
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow must have CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const titlePos = await clipNodes[0].getTitlePosition()
const CLICK_GAP_MS = 200
await test.step(`Gap (${CLICK_GAP_MS}ms) exceeds DoubleClickTime → editor stays hidden`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
100
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectHidden()
})
await test.step(`Gap (${CLICK_GAP_MS}ms) within DoubleClickTime → editor opens`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
1000
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectVisible()
})
})
test('ClickBufferTime governs the click-vs-drag time threshold', async ({
comfyPage
}) => {
// Keep drift generous so only elapsed time distinguishes click vs drag.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 2
const HOLD_MS = 250
await test.step(`Buffer=2000ms (hold=${HOLD_MS}ms within buffer) → click, node stays put`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.ClickBufferTime',
2000
)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Buffer=50ms (hold=${HOLD_MS}ms exceeds buffer) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 50)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
test('ClickDrift governs the click-vs-drag distance threshold', async ({
comfyPage
}) => {
// Keep buffer generous so only drift distance matters.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 2000)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 8
await test.step(`Drift=20px (nudge=${NUDGE}px within tolerance) → click, node stays put`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Drift=1px (nudge=${NUDGE}px exceeds tolerance) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 1)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
})
test.describe('LiteGraph.Canvas.MaximumFps', () => {
// Behavioural FPS counting via rAF is not reliable under Playwright
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
// render-loop throttle value instead — that is what actually governs
// frame cadence.
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
test('caps the render loop frame gap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 30, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 60)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 60, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 0)
await expect.poll(() => getFrameGap(comfyPage)).toBe(0)
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -12,25 +13,35 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
const test = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
async ({ cloudAssetRequests, page }, use) => {
const pattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
)
}
await page.route(pattern, assetsRouteHandler)
await use()
await page.unroute(pattern)
await page.unroute(pattern, assetsRouteHandler)
},
{ auto: true }
]
@@ -42,23 +53,36 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
})
test('should use first cloud asset when server default is not in assets', async ({
cloudAssetRequests,
comfyPage
}) => {
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
// Wait for the checkpoint asset query to complete and the existing widget
// to upgrade into asset mode before creating a fresh node. The current
// default node may keep a previously resolved value; what matters is that
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
cloudAssetRequests.some((url) => {
const includeTags =
new URL(url).searchParams.get('include_tags') ?? ''
return includeTags.split(',').includes('checkpoints')
})
)
.toBe(true)
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
comfyPage.page.evaluate((waitingForWidgetType) => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
return (
node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type ?? waitingForWidgetType
)
}, WAITING_FOR_WIDGET_TYPE),
{ timeout: 10_000 }
)
.toBe('asset')
@@ -81,15 +105,22 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
await expect
.poll(
() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
comfyPage.page.evaluate(
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return waitingForWidgetType
const val = String(widget?.value ?? '')
return val === 'Select model' ? waitingForWidgetValue : val
},
{
id: nodeId,
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
}
),
{ timeout: 15_000 }
)
.toBe(CLOUD_ASSETS[0].name)

View File

@@ -0,0 +1,306 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
const PUBLISH_FEATURE_FLAGS = {
comfyhub_upload_enabled: true,
comfyhub_profile_gate_enabled: true
} as const
async function saveAndOpenPublishDialog(
comfyPage: ComfyPage,
dialog: PublishDialog,
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
await comfyPage.confirmDialog.click('overwrite')
} catch {
// No overwrite dialog — workflow name was unique.
}
await dialog.open()
}
test.describe('Publish dialog - wizard navigation', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
})
test('opens on the Describe step by default', async ({ publishDialog }) => {
await expect(publishDialog.describeStep).toBeVisible()
await expect(publishDialog.nameInput).toBeVisible()
await expect(publishDialog.descriptionTextarea).toBeVisible()
})
test('pre-fills workflow name from active workflow', async ({
publishDialog
}) => {
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
})
test('Next button navigates to Examples step', async ({ publishDialog }) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
// Examples step should show thumbnail toggle and upload area
await expect(
publishDialog.root.getByText('Select a thumbnail')
).toBeVisible()
})
test('Back button returns to Describe step from Examples', async ({
publishDialog
}) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
await publishDialog.goBack()
await expect(publishDialog.describeStep).toBeVisible()
})
test('navigates through all steps to Finish', async ({ publishDialog }) => {
await publishDialog.goNext() // → Examples
await publishDialog.goNext() // → Finish
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeVisible()
})
test('clicking nav step navigates directly', async ({ publishDialog }) => {
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.goToStep('Describe your workflow')
await expect(publishDialog.describeStep).toBeVisible()
})
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
await comfyPage.page.keyboard.press('Escape')
await expect(publishDialog.root).toBeHidden()
})
})
test.describe('Publish dialog - Describe step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
})
test('allows editing the workflow name', async ({ publishDialog }) => {
await publishDialog.nameInput.clear()
await publishDialog.nameInput.fill('My Custom Workflow')
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
})
test('allows editing the description', async ({ publishDialog }) => {
await publishDialog.descriptionTextarea.fill(
'A great workflow for anime art'
)
await expect(publishDialog.descriptionTextarea).toHaveValue(
'A great workflow for anime art'
)
})
test('displays tag suggestions from mocked API', async ({
publishDialog
}) => {
await expect(publishDialog.root.getByText('anime')).toBeVisible()
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
})
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
// the active list during E2E. Needs investigation of the parent state
// binding.
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
await publishDialog.root.getByText('anime').click()
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
})
})
test.describe('Publish dialog - Examples step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
await publishDialog.goNext() // Navigate to Examples step
})
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByText('Image', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Video', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Image comparison', { exact: true })
).toBeVisible()
})
test('shows example image upload tile', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByRole('button', { name: 'Upload example image' })
).toBeVisible()
})
})
test.describe('Publish dialog - Finish step with profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
await publishDialog.goToStep('Finish publishing')
})
test('shows profile card with username', async ({ publishDialog }) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
})
test('publish button is enabled when no private assets', async ({
publishDialog
}) => {
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - Finish step with private assets', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({
hasProfile: true,
hasPrivateAssets: true
})
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
await publishDialog.goToStep('Finish publishing')
})
test('publish button is disabled until assets acknowledged', async ({
publishDialog
}) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeDisabled()
const checkbox = publishDialog.finishStep.getByRole('checkbox')
await checkbox.check()
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - no profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: false })
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-noprofile-wf'
)
await publishDialog.goToStep('Finish publishing')
})
test('shows profile creation prompt when user has no profile', async ({
publishDialog
}) => {
await expect(publishDialog.profilePrompt).toBeVisible()
await expect(
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
).toBeVisible()
})
test('clicking create profile CTA shows profile creation form', async ({
publishDialog
}) => {
await publishDialog.root
.getByRole('button', { name: 'Create a profile' })
.click()
await expect(publishDialog.gateFlow).toBeVisible()
})
})
test.describe('Publish dialog - unsaved workflow', () => {
test.beforeEach(async ({ comfyPage, publishApi }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
// Don't save workflow — open dialog on the default temporary workflow
})
test('shows save prompt for temporary workflow', async ({
comfyPage,
publishDialog
}) => {
// Create a new workflow to ensure it's temporary
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await publishDialog.open()
await expect(publishDialog.savePrompt).toBeVisible()
await expect(
publishDialog.root.getByText(
'You must save your workflow before publishing'
)
).toBeVisible()
// Nav should be hidden when save is required
await expect(publishDialog.nav).toBeHidden()
})
})
test.describe('Publish dialog - submission', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
})
test('successful publish closes dialog', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
})
test('failed publish shows error toast', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
// Override publish mock with error response
await publishApi.mockPublishWorkflowError(500, 'Internal error')
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-submit-fail-wf'
)
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
// Error toast should appear
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
timeout: 10_000
})
// Dialog should remain open
await expect(publishDialog.root).toBeVisible()
})
})

View File

@@ -8,7 +8,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/helpers/clipboardSpy'
} from '@e2e/fixtures/utils/clipboardSpy'
async function triggerConfigureError(
comfyPage: ComfyPage,

View File

@@ -0,0 +1,208 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
const VAE_DECODE_SAMPLES_INPUT_SLOT = 0
const DEFAULT_GROUP_TITLE = 'Group'
test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
test.describe('Comfy.LinkRelease.Action', () => {
test('"search box" opens node search on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
test('"context menu" opens litegraph connection menu on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'context menu'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
})
test('"no action" suppresses both search box and context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeHidden()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
})
})
test.describe('Comfy.LinkRelease.ActionShift', () => {
test('shift+drag dispatches to ActionShift (not Action)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.canvasOps.disconnectEdge({ modifiers: ['Shift'] })
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
})
test.describe('Comfy.Node.DoubleClickTitleToEdit', () => {
test('enabled → double-click on node title opens editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
true
)
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
await comfyPage.titleEditor.expectVisible()
})
test('disabled → double-click on node title stays hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
false
)
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
await comfyPage.titleEditor.expectHidden()
})
})
test.describe('Comfy.Group.DoubleClickTitleToEdit', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
})
test('enabled → double-click on group title opens editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Group.DoubleClickTitleToEdit',
true
)
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
await comfyPage.titleEditor.expectVisible()
})
test('disabled → double-click on group title stays hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Group.DoubleClickTitleToEdit',
false
)
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
await comfyPage.titleEditor.expectHidden()
})
})
test.describe('Comfy.Node.BypassAllLinksOnDelete', () => {
test('enabled → deleting KSampler bridges EmptyLatentImage → VAEDecode.samples', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.BypassAllLinksOnDelete',
true
)
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const [emptyLatent] =
await comfyPage.nodeOps.getNodeRefsByType('EmptyLatentImage')
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
const vaeSamplesInput = await vaeDecode.getInput(
VAE_DECODE_SAMPLES_INPUT_SLOT
)
await test.step('precondition: KSampler feeds VAEDecode.samples', async () => {
expect(
(await vaeSamplesInput.getLink())?.origin_id,
'VAEDecode.samples should originate from KSampler before delete'
).toBe(kSampler.id)
})
await kSampler.delete()
await expect
.poll(async () => (await vaeSamplesInput.getLink())?.origin_id ?? null)
.toBe(emptyLatent.id)
})
test('disabled → deleting KSampler drops VAEDecode.samples', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.BypassAllLinksOnDelete',
false
)
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
const vaeSamplesInput = await vaeDecode.getInput(
VAE_DECODE_SAMPLES_INPUT_SLOT
)
await kSampler.delete()
await expect.poll(() => vaeSamplesInput.getLink()).toBeNull()
})
})
test.describe('Comfy.Node.MiddleClickRerouteNode', () => {
async function countReroutes(comfyPage: ComfyPage): Promise<number> {
return (await comfyPage.nodeOps.getNodeRefsByType('Reroute')).length
}
test('enabled → middle-click on an output slot creates a Reroute', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
true
)
const before = await countReroutes(comfyPage)
await comfyPage.canvasOps.middleClick(
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
)
await expect.poll(() => countReroutes(comfyPage)).toBe(before + 1)
})
test('disabled → middle-click on an output slot does nothing', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
false
)
const before = await countReroutes(comfyPage)
await comfyPage.canvasOps.middleClick(
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
)
await comfyPage.nextFrame()
expect(await countReroutes(comfyPage)).toBe(before)
})
})
})

View File

@@ -0,0 +1,106 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SignInDialog } from '@e2e/fixtures/components/SignInDialog'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Enable the show_signin_button server feature flag so LoginButton renders
* in WorkflowTabs (which uses `flags.showSignInButton ?? isDesktop`).
* The flag is reset automatically on each fresh page load in beforeEach.
*/
async function enableLoginButtonFlag(page: Page): Promise<void> {
await page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: true
}
})
}
test.describe('Login Button', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
test.describe('Visibility', () => {
test('button is hidden when show_signin_button flag is off', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: false
}
})
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeHidden()
})
test('button is visible when show_signin_button flag is enabled', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeVisible()
})
})
test.describe('ARIA', () => {
test('button has correct aria-label', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await expect(button).toHaveAttribute('aria-label', /.+/)
})
})
test.describe('Click behaviour', () => {
test('clicking the button opens the sign-in dialog', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const dialog = new SignInDialog(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).click()
await expect(dialog.root).toBeVisible()
})
})
test.describe('Hover popover', () => {
test('hovering shows an informational popover', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
})
test('popover contains a Learn more link', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
const learnMoreLink = comfyPage.page.getByTestId(
TestIds.topbar.loginButtonPopoverLearnMore
)
await expect(learnMoreLink).toBeVisible()
await expect(learnMoreLink).toHaveAttribute('href', /api-nodes/)
})
test('popover hides after mouse leaves the button area', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await button.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
await comfyPage.canvas.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeHidden()
})
})
})

View File

@@ -22,18 +22,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
return { nodeId: nodeRef.id, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
@@ -80,7 +76,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
@@ -153,5 +148,124 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()
const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()
// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()
// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()
// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)
// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)
// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
test(
'subgraph blueprint added from search box enters ghost mode',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
// Convert a node to a subgraph and publish it as a blueprint
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.nextFrame()
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
expect(subgraphNodes).toHaveLength(1)
const subgraphNode = subgraphNodes[0]
const blueprintName = `ghost-test-${Date.now()}`
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
// Open v2 search box and search for the published blueprint
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill(blueprintName)
await expect(searchBoxV2.results.first()).toBeVisible()
// Click the result to add the node (v2 search box uses ghost mode)
await searchBoxV2.results.first().click()
await comfyPage.nextFrame()
// A new node should exist on the graph in ghost mode
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
const ghostNodeId = await comfyPage.page.evaluate(() => {
return window.app!.canvas.state.ghostNodeId
})
expect(ghostNodeId).not.toBeNull()
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
expect(ghostState).not.toBeNull()
expect(ghostState!.ghost).toBe(true)
// Wait for search box to close, then click to confirm placement
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page.mouse.click(
Math.round(viewport.width / 2),
Math.round(viewport.height / 2)
)
await comfyPage.nextFrame()
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
expect(afterPlace).not.toBeNull()
expect(afterPlace!.ghost).toBe(false)
}
)
})
}

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'

View File

@@ -5,32 +5,19 @@ import {
test.describe('Node search box V2', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
await comfyPage.searchBoxV2.setup()
})
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
@@ -40,33 +27,28 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Default results should be visible without typing
await searchBoxV2.open()
// Default results should be visible without typing.
await expect(searchBoxV2.results.first()).toBeVisible()
// Enter should add the first (selected) result
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test.describe('Category navigation', () => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
])
await searchBoxV2.reload(comfyPage)
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('favorites').click()
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('favorites').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -77,13 +59,10 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
})
@@ -91,26 +70,23 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
// Click "Input" filter chip in the filter bar
await searchBoxV2.filterBarButton('Input').click()
await test.step('Open Input filter popover', async () => {
await searchBoxV2.typeFilterButton('input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
})
// Filter options should appear
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await test.step('Select MODEL type', async () => {
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
})
// Type to narrow and select MODEL
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Filter chip should appear and results should be filtered
await expect(
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
).toContainText('MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
await expect(searchBoxV2.results.first()).toBeVisible()
})
})
@@ -120,32 +96,33 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
// First result selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('First result is selected by default', async () => {
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// ArrowDown moves selection
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
await test.step('ArrowDown moves selection to next result', async () => {
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
})
// ArrowUp moves back
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('ArrowUp moves selection back', async () => {
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// Enter selects and adds node
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
await test.step('Enter selects and adds the node', async () => {
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
})
})
})

View File

@@ -2,27 +2,17 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
await comfyPage.searchBoxV2.setup()
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.openByDoubleClickCanvas()
await expect(searchBoxV2.dialog).toBeVisible()
})
@@ -32,43 +22,40 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
test('Search clears when reopening', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
for (const closeKey of ['Enter', 'Escape'] as const) {
test(`Reopening search after ${closeKey} has no persisted state`, async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press(closeKey)
await expect(searchBoxV2.input).toBeHidden()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.input).toHaveValue('')
})
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
}
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -76,7 +63,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(samplingResults)
@@ -87,58 +73,328 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
// Record initial result text for comparison
// Search first to keep the result set under the 64-item cap.
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredResults = await searchBoxV2.results.allTextContents()
const unfilteredCount = await searchBoxV2.results.count()
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
await test.step('Apply Input/MODEL filter', async () => {
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(unfilteredResults)
// Remove filter by clicking the chip delete button
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed
await expect(filterChip).toBeHidden()
await expect(searchBoxV2.results.first()).toBeVisible()
await test.step('Remove the filter chip', async () => {
await searchBoxV2.removeFilterChip()
await expect(searchBoxV2.filterChips).toHaveCount(0)
await expect(searchBoxV2.results).toHaveCount(unfilteredCount)
})
})
})
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
test.describe('Link release', () => {
test('Link release opens search with pre-applied type filter', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
// disconnectEdge pulls a CLIP link → expect a single CLIP filter chip.
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
})
test('Link release auto-connects added node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const NODE_TYPE = 'CLIPTextEncode'
const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const idsBefore = new Set(refsBefore.map((n) => n.id))
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('CLIP Text Encode')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
// A new CLIPTextEncode node should have been added.
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByType(NODE_TYPE)
.then((refs) => refs.length)
)
.toBe(refsBefore.length + 1)
// Verify the auto-connect: the newly-added node's CLIP input must be
// connected (proves the release wasn't just dropped).
const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const newNode = refsAfter.find((n) => !idsBefore.has(n.id))
expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined()
const clipInput = await newNode!.getInput(0)
await expect.poll(() => clipInput.getLinkCount()).toBe(1)
})
})
test.describe('Filter combinations', () => {
test('Output type filter filters results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'IMAGE')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
test('Multiple type filters (Input + Output) narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.results.first()).toBeVisible()
const singleFilterCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(singleFilterCount)
})
test('Root filter + search query narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Sampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(unfilteredCount)
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
test('Root filter + category selection', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const comfyCount = await searchBoxV2.results.count()
// Under root filter, categories are prefixed (e.g. comfy/sampling).
await searchBoxV2.categoryButton('comfy/sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(comfyCount)
})
})
test.describe('Category sidebar', () => {
test('Category tree expand and collapse', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingBtn = searchBoxV2.categoryButton('sampling')
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await test.step('Expanding sampling reveals its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeVisible()
})
await test.step('Collapsing sampling hides its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeHidden()
})
})
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const parentCount = await searchBoxV2.results.count()
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
await subcategory.click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(parentCount)
})
test('Most relevant resets category filter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.results.first()).toBeVisible()
const defaultCount = await searchBoxV2.results.count()
await searchBoxV2.categoryButton('sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(defaultCount)
await searchBoxV2.categoryButton('most-relevant').click()
await expect(searchBoxV2.results).toHaveCount(defaultCount)
})
test(
'Blueprint root chip filters to published blueprints',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}`
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByTitle('New Subgraph')
.then((refs) => refs.length)
)
.toBe(1)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
await subgraphNodes[0].click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const blueprintsChip = searchBoxV2.rootCategoryButton(
RootCategory.Blueprint
)
await expect(blueprintsChip).toBeVisible()
await blueprintsChip.click()
// Blueprints persist across tests on the same worker; filter by the
// unique name we just published rather than asserting the full list.
await expect(
searchBoxV2.results.filter({ hasText: blueprintName })
).toHaveCount(1)
}
)
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const getCount = () => searchBoxV2.results.count()
await searchBoxV2.open()
await searchBoxV2.input.fill('S')
await expect(searchBoxV2.results.first()).toBeVisible()
const count1 = await getCount()
await searchBoxV2.input.fill('Sa')
await expect.poll(getCount).toBeLessThan(count1)
const count2 = await getCount()
await searchBoxV2.input.fill('Sampler')
await expect.poll(getCount).toBeLessThan(count2)
})
test('No results shown for nonsensical query', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
await expect(searchBoxV2.noResults).toBeVisible()
await expect(searchBoxV2.results).toHaveCount(0)
})
})
test.describe('Filter chip interaction', () => {
test('Multiple filter chips displayed', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
const chipTexts = await searchBoxV2.filterChips.allTextContents()
expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true)
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
})
})
test.describe('Settings-driven behavior', () => {
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.ShowIdName',
true
)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('VAE Decode')
await expect(searchBoxV2.results.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
})
test('Follow-cursor disabled places node without ghost mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
false
)
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).toBeHidden()
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
await expect(
comfyPage.page.locator('[data-node-id][data-ghost]')
).toHaveCount(0)
})
})
})

View File

@@ -7,7 +7,7 @@ import {
drawStroke,
hasCanvasContent,
triggerSerialization
} from '@e2e/helpers/painter'
} from '@e2e/fixtures/utils/painter'
import type { TestGraphAccess } from '@e2e/types/globals'
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transform1)
await comfyPage.page.mouse.move(0, 0)
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
await expect(cursor).toBeHidden()
})
@@ -187,7 +187,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
await comfyPage.page.mouse.move(
box.x + box.width + 20,
box.y + box.height * 0.5
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
@@ -408,6 +411,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
// default 512 + slider step 64 = 576
.toBe(576)
})
@@ -493,6 +497,29 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBe(false)
}
)
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should start empty'
})
.toBe(false)
await painterWidget
.getByTestId('painter-clear-button')
.dispatchEvent('click')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should still be empty after clearing empty canvas'
})
.toBe(false)
})
})
test.describe('Serialization', () => {
@@ -560,36 +587,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
test.describe('Eraser', () => {
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
})
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
@@ -604,18 +601,318 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
test.describe('Serialization — unchanged canvas', () => {
test(
'Unchanged canvas does not re-upload on second serialization',
{ tag: '@slow' },
async ({ comfyPage }) => {
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockResponse)
})
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'first serialization should upload once').toBe(1)
await triggerSerialization(comfyPage.page)
expect(
uploadCount,
'second serialization without new drawing should not re-upload'
).toBe(1)
}
)
})
test.describe('Settings persistence', () => {
test('Tool selection is saved to node properties', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
| string
| undefined
}),
{ message: 'painterTool property should update to eraser' }
)
.toBe('eraser')
})
test('Brush size change is saved to node properties', async ({
comfyPage
}) => {
const sizeRow = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
.getByTestId('painter-size-row')
const sizeSlider = sizeRow.getByRole('slider')
await expect(
sizeRow.getByTestId('painter-size-value'),
'brush size should start at default 20'
).toHaveText('20')
await sizeSlider.focus()
for (let i = 0; i < 10; i++) {
await sizeSlider.press('ArrowRight')
}
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties
?.painterBrushSize as number | undefined
}),
{ message: 'painterBrushSize property should update to 30' }
)
.toBe(30)
})
})
test('Controls collapse to single column in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
await expect(
toolLabel,
'tool label should be visible in two-column layout'
).toBeVisible()
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
if (node) {
node.size = [200, 400]
window.app!.canvas.setDirty(true, true)
}
})
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
})
test('Multiple sequential strokes at different positions all accumulate', async ({
comfyPage
}) => {
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
const hasContentAtRow = (yFraction: number) =>
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cy = Math.floor(el.height * y)
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true
}
return false
}, yFraction)
await expect
.poll(() => hasContentAtRow(0.25), {
message: 'top stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.5), {
message: 'middle stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.75), {
message: 'bottom stroke should be present'
})
.toBe(true)
})
})
test.describe(
'Painter — input image connection',
{ tag: ['@widget', '@vue-nodes', '@slow'] },
() => {
test.setTimeout(60_000)
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
})
test('Width, height, and bg_color controls hide when input is connected', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(
painterWidget.getByTestId('painter-width-row'),
'width row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-height-row'),
'height row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-bg-color-row'),
'background color row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-dimension-text'),
'dimension text should be visible when input is connected'
).toBeVisible()
})
test('Canvas resizes to match input image dimensions after execution', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{
message: 'input image should be fully decoded',
timeout: 30_000
}
)
.toBe(true)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas width should match input image natural width'
})
.toBe(nw)
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
message: 'canvas height should match input image natural height'
})
.toBe(nh)
})
test('Drawing over input image produces content on canvas', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{ message: 'input image should be fully decoded', timeout: 30_000 }
)
.toBe(true)
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas should resize to match input image width',
timeout: 15_000
})
.toBe(nw)
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
// intercepts coordinate-based hit testing from page.mouse
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
const startX = box.x + box.width * 0.3
const endX = box.x + box.width * 0.7
const midY = box.y + box.height * 0.5
const pointerOpts = {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
isPrimary: true
}
await canvas.dispatchEvent('pointerdown', {
...pointerOpts,
clientX: startX,
clientY: midY
})
for (let i = 1; i <= 10; i++) {
await canvas.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + (endX - startX) * (i / 10),
clientY: midY
})
}
await canvas.dispatchEvent('pointerup', {
...pointerOpts,
clientX: endX,
clientY: midY
})
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'drawing over input image should produce canvas content'
})
.toBe(true)
})
}
)

View File

@@ -1,7 +1,10 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { logMeasurement, recordMeasurement } from '@e2e/helpers/perfReporter'
import {
logMeasurement,
recordMeasurement
} from '@e2e/fixtures/utils/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
export class PropertiesPanelHelper {
@@ -8,12 +9,14 @@ export class PropertiesPanelHelper {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly closeButton: Locator
readonly titleEditor: TitleEditor
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.closeButton = this.root.locator('button[aria-pressed]')
this.titleEditor = new TitleEditor(this.root)
}
get tabs(): Locator {
@@ -28,10 +31,6 @@ export class PropertiesPanelHelper {
return this.panelTitle.locator('i[class*="lucide--pencil"]')
}
get titleInput(): Locator {
return this.root.getByTestId(TestIds.node.titleInput)
}
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
return this.root.locator('button', { hasText: state })
}
@@ -86,8 +85,8 @@ export class PropertiesPanelHelper {
async editTitle(newTitle: string): Promise<void> {
await this.titleEditIcon.click()
await this.titleInput.fill(newTitle)
await this.titleInput.press('Enter')
await this.titleEditor.expectVisible()
await this.titleEditor.setTitle(newTitle)
}
async searchWidgets(query: string): Promise<void> {

View File

@@ -5,7 +5,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/helpers/clipboardSpy'
} from '@e2e/fixtures/utils/clipboardSpy'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
@@ -99,5 +99,58 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
)
await expect(downloadButton.first()).toBeVisible()
})
test('Should render Download all and Refresh actions for one downloadable model', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
).toBeVisible()
})
test('Should clear resolved missing model when Refresh is clicked', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await comfyPage.page.route(/\/object_info$/, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
})
})
})

View File

@@ -18,7 +18,7 @@ test.describe('Properties panel - Title editing', () => {
test('should enter edit mode on pencil click', async () => {
await panel.titleEditIcon.click()
await expect(panel.titleInput).toBeVisible()
await panel.titleEditor.expectVisible()
})
test('should update node title on edit', async () => {

View File

@@ -2,7 +2,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
test('Properties panel opens with workflow overview', async ({
@@ -35,11 +34,8 @@ test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
// Click on the title to enter edit mode
await propertiesPanel.panelTitle.click()
const titleInput = propertiesPanel.root.getByTestId(TestIds.node.titleInput)
await expect(titleInput).toBeVisible()
await titleInput.fill('My Custom Sampler')
await titleInput.press('Enter')
await propertiesPanel.titleEditor.expectVisible()
await propertiesPanel.titleEditor.setTitle('My Custom Sampler')
await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler')
})

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
import { measureSelectionBounds } from '@e2e/fixtures/utils/boundsUtils'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const SUBGRAPH_ID = '2'

View File

@@ -5,7 +5,10 @@ import {
createMockJob,
createMockJobs
} from '@e2e/fixtures/helpers/AssetsHelper'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
// ---------------------------------------------------------------------------
// Shared fixtures
@@ -62,6 +65,37 @@ const SAMPLE_IMPORTED_FILES = [
'audio_clip.wav'
]
const JOB_GAMMA_DETAIL: JobDetail = {
...SAMPLE_JOBS[2],
outputs: {
'3': {
images: [
{
filename: 'abstract_art.png',
subfolder: '',
type: 'output'
},
{
filename: 'abstract_art_alt.png',
subfolder: '',
type: 'output'
}
]
}
}
}
const cloudTest = test.extend<{ mockCloudAssetSidebarData: void }>({
mockCloudAssetSidebarData: async ({ comfyPage }, use) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockEmptyCloudAssets()
await use()
await comfyPage.assets.clearMocks()
}
})
// ==========================================================================
// 1. Empty states
// ==========================================================================
@@ -633,6 +667,96 @@ test.describe('Assets sidebar - bulk actions', () => {
})
})
cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
cloudTest(
'Single job selection uses preserve naming strategy',
async ({ comfyPage, mockCloudAssetSidebarData }) => {
void mockCloudAssetSidebarData
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
await expect(tab.downloadSelectedButton).toBeVisible()
await tab.downloadSelectedButton.click()
await expect.poll(() => exportRequests).toHaveLength(1)
const payload = exportRequests[0]
expect(payload.job_ids).toEqual(['job-gamma'])
expect(payload.job_asset_name_filters).toBeUndefined()
expect(payload.naming_strategy).toBe('preserve')
}
)
cloudTest(
'Multiple selected assets from one job use preserve naming strategy',
async ({ comfyPage, mockCloudAssetSidebarData }) => {
void mockCloudAssetSidebarData
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards
.first()
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect.poll(() => tab.assetCards.count()).toBe(2)
await tab.assetCards.first().click()
await comfyPage.page.keyboard.down('Control')
await tab.assetCards.nth(1).click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await tab.downloadSelectedButton.click()
await expect.poll(() => exportRequests).toHaveLength(1)
const payload = exportRequests[0]
expect(payload.job_ids).toEqual(['job-gamma'])
expect(payload.job_asset_name_filters?.['job-gamma']?.toSorted()).toEqual(
['abstract_art.png', 'abstract_art_alt.png']
)
expect(payload.naming_strategy).toBe('preserve')
}
)
cloudTest(
'Multiple selected jobs use job-time naming strategy',
async ({ comfyPage, mockCloudAssetSidebarData }) => {
void mockCloudAssetSidebarData
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
await tab.assetCards.nth(2).click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await tab.downloadSelectedButton.click()
await expect.poll(() => exportRequests).toHaveLength(1)
const payload = exportRequests[0]
expect(payload.job_ids?.toSorted()).toEqual(['job-alpha', 'job-beta'])
expect(payload.job_asset_name_filters).toBeUndefined()
expect(payload.naming_strategy).toBe('group_by_job_time')
}
)
})
// ==========================================================================
// 9. Pagination
// ==========================================================================

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'

View File

@@ -3,11 +3,11 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import {
getPromotedWidgetNames,
getPromotedWidgetCount
} from '@e2e/helpers/promotedWidgets'
} from '@e2e/fixtures/utils/promotedWidgets'
async function expectPromotedWidgetNamesToContain(
comfyPage: ComfyPage,

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`

View File

@@ -1,39 +1,73 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
import {
getPromotedWidgetCount,
getPromotedWidgetNames,
getPromotedWidgets
} from '@e2e/fixtures/utils/promotedWidgets'
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const getPromotedHostWidgetValues = async (
comfyPage: ComfyPage,
nodeIds: string[]
) => {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) {
expect(widgets.length).toBeGreaterThan(0)
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
}, nodeIds)
}
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
expect(results).toEqual(widgets.map(() => true))
}
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
@@ -86,54 +120,434 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test.describe('Legacy prefixed proxyWidget normalization', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
test.describe('Deterministic proxyWidgets Hydrate', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const workflowName =
'subgraphs/subgraph-multi-instance-promoted-text-values'
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
expect(initialValues.map(({ values }) => values[0])).toEqual(
expectedValues
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
hostNodeIds
'11',
afterFirst
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
await comfyPage.subgraph.serializeAndReload()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
let previousUseNewMenu: unknown
test.beforeEach(async ({ comfyPage }) => {
previousUseNewMenu =
await comfyPage.settings.getSetting('Comfy.UseNewMenu')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.UseNewMenu',
previousUseNewMenu
)
})
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
).toBe(false)
expect(
promotedWidgets.some(
([interiorNodeId, widgetName]) =>
interiorNodeId !== '-1' && widgetName === 'batch_size'
)
).toBe(true)
})
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
await comfyPage.subgraph.serializeAndReload()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
await comfyPage.subgraph.serializeAndReload()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
})
})
test.describe('Duplicate ID Remapping', () => {
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})
test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await expect
.poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), {
timeout: 5_000
})
.toEqual(beforeSnapshot)
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]
const SENTINEL_IDS = new Set([-1, -10, -20])
const isSentinelNodeId = (id: number | string): id is number =>
typeof id === 'number' && SENTINEL_IDS.has(id)
const checkEndpoint = (
label: string,
kind: 'origin_id' | 'target_id',
id: number | string,
g: typeof graph
): string | null => {
if (isSentinelNodeId(id)) return null
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
return `${label}: ${kind} ${id} invalid or not found`
}
return null
}
return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
checkEndpoint(label, 'origin_id', link.origin_id, g),
checkEndpoint(label, 'target_id', link.target_id, g)
].filter((e): e is string => e !== null)
)
)
})
expect(invalidLinks).toEqual([])
})
test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy Prefixed proxyWidget Normalization',
{ tag: ['@subgraph', '@widget'] },
() => {
let previousVueNodesEnabled: unknown
test.beforeEach(async ({ comfyPage }) => {
previousVueNodesEnabled = await comfyPage.settings.getSetting(
'Comfy.VueNodes.Enabled'
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
previousVueNodesEnabled
)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page
)
try {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
comfyExpect(warnings).toEqual([])
} finally {
dispose()
}
})
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
})
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

@@ -7,7 +7,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
test.describe(
'Vue Node Bring to Front',

View File

@@ -66,10 +66,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Rename')
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
await titleInput.waitFor({ state: 'visible' })
await titleInput.fill('My Renamed Sampler')
await titleInput.press('Enter')
await comfyPage.titleEditor.expectVisible()
await comfyPage.titleEditor.setTitle('My Renamed Sampler')
await comfyPage.nextFrame()
const renamedNode =

View File

@@ -5,7 +5,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/helpers/promotedWidgets'
} from '@e2e/fixtures/utils/promotedWidgets'
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
test('should display node title', async ({ comfyPage }) => {
@@ -22,8 +21,8 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
// Test cancel with Escape
await vueNode.title.dblclick()
await comfyPage.nextFrame()
await vueNode.titleInput.fill('This Should Be Cancelled')
await vueNode.titleInput.press('Escape')
await vueNode.titleEditor.input.fill('This Should Be Cancelled')
await vueNode.titleEditor.cancel()
await comfyPage.nextFrame()
// Title should remain as the previously saved value
@@ -40,9 +39,6 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()
const editingTitleInput = comfyPage.page.getByTestId(
TestIds.node.titleInput
)
await expect(editingTitleInput).toBeHidden()
await comfyPage.titleEditor.expectHidden()
})
})

View File

@@ -285,9 +285,12 @@ export default defineConfig([
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
}
],
// Tests routinely define stub and harness components side-by-side with the
// system under test, which is a distinct use case from production SFCs.
'vue/one-component-per-file': 'off'
// Tests routinely define stub and harness components side-by-side with
// the system under test and stub emits for documentation only — these
// production-SFC rules are noise in a test file.
'vue/one-component-per-file': 'off',
'vue/no-reserved-component-names': 'off',
'vue/no-unused-emit-declarations': 'off'
}
},
{
@@ -472,6 +475,11 @@ export default defineConfig([
{
group: ['./**', '../**'],
message: 'Use the @e2e/ path alias instead of relative imports.'
},
{
group: ['@e2e/helpers', '@e2e/helpers/*'],
message:
'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.'
}
]
}
@@ -490,6 +498,11 @@ export default defineConfig([
{
group: ['./**', '../**'],
message: 'Use the @e2e/ path alias instead of relative imports.'
},
{
group: ['@e2e/helpers', '@e2e/helpers/*'],
message:
'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.'
}
]
}

View File

@@ -17,6 +17,9 @@ const config: KnipConfig = {
entry: ['src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/design-system': {
project: ['src/**/*.{css,js,ts}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},

View File

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

View File

@@ -73,10 +73,6 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
--color-bypass: #6a246a;
--color-error: #962a2a;
@@ -589,8 +585,6 @@
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {

View File

@@ -3825,14 +3825,14 @@ export type CreateAssetExportData = {
/**
* Strategy for naming files in the ZIP:
* - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png")
* - prepend_job_id: Prepend job ID to filenames for uniqueness (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4_ComfyUI_00001_.png")
* - group_by_job_time: Group assets by job execution time as parent directories
* - preserve: Use original asset names, skip duplicates (first one wins)
* - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png")
*
*/
naming_strategy?:
| 'group_by_job_id'
| 'prepend_job_id'
| 'group_by_job_time'
| 'preserve'
| 'asset_id'
/**

View File

@@ -1818,7 +1818,7 @@ export const zCreateAssetExportData = z.object({
job_ids: z.array(z.string()).optional(),
asset_ids: z.array(z.string()).optional(),
naming_strategy: z
.enum(['group_by_job_id', 'prepend_job_id', 'preserve', 'asset_id'])
.enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
}),

View File

@@ -14495,7 +14495,7 @@ export interface components {
* @description The ID of the model to call
* @enum {string}
*/
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit";
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit" | "happyhorse-1.0-t2v" | "happyhorse-1.0-i2v" | "happyhorse-1.0-r2v" | "happyhorse-1.0-video-edit";
/** @description Enter basic information, such as prompt words, etc. */
input: {
/**

View File

@@ -202,6 +202,28 @@ describe('formatUtil', () => {
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
)
})
it('should highlight cross-word matches', () => {
const result = highlightQuery('convert image to mask', 'geto', false)
expect(result).toBe(
'convert ima<span class="highlight">ge to</span> mask'
)
})
it('should not match across line breaks', () => {
const result = highlightQuery('ge\nto', 'geto', false)
expect(result).toBe('ge\nto')
})
it('should not match across tabs', () => {
const result = highlightQuery('ge\tto', 'geto', false)
expect(result).toBe('ge\tto')
})
it('should not match across multiple spaces', () => {
const result = highlightQuery('ge to', 'geto', false)
expect(result).toBe('ge to')
})
})
describe('getFilenameDetails', () => {

View File

@@ -74,10 +74,14 @@ export function highlightQuery(
text = DOMPurify.sanitize(text)
}
// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Escape special regex characters, then join with an optional single
// space so cross-word matches (e.g. "geto" → "imaGE TO") are
// highlighted without spanning tabs, newlines, or multi-space gaps.
const pattern = Array.from(query)
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[ ]?')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
const regex = new RegExp(`(${pattern})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

View File

@@ -1,9 +1,17 @@
import { clsx } from 'clsx'
import type { ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { extendTailwindMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': ['text-xxs', 'text-xxxs']
}
}
})
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}

View File

@@ -26,7 +26,7 @@ const ScrubableNumberInputStub = defineComponent({
step: { type: Number, default: 1 },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<input

View File

@@ -1,5 +1,3 @@
/* 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'

View File

@@ -1,4 +1,3 @@
/* eslint-disable vue/no-reserved-component-names */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -100,7 +99,7 @@ const WidgetBoundingBoxStub = defineComponent({
modelValue: { type: Object, default: () => ({}) },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `<div data-testid="bbox-child"
:data-disabled="String(disabled)"

View File

@@ -0,0 +1,254 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import Load3D from '@/components/load3d/Load3D.vue'
import type { ComponentWidget } from '@/scripts/domWidget'
const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({
load3dState: {
current: null as ReturnType<typeof buildLoad3dStub> | null
},
resolveNodeMock: vi.fn(),
settingGetMock: vi.fn()
}))
function buildLoad3dStub() {
return {
sceneConfig: ref({}),
modelConfig: ref({}),
cameraConfig: ref({}),
lightConfig: ref({}),
isRecording: ref(false),
isPreview: ref(false),
canFitToViewer: ref(true),
canUseGizmo: ref(true),
canUseLighting: ref(true),
canExport: ref(true),
materialModes: ref(['original', 'normal', 'wireframe']),
hasSkeleton: ref(false),
hasRecording: ref(false),
recordingDuration: ref(0),
animations: ref<Array<{ name: string; index: number }>>([]),
playing: ref(false),
selectedSpeed: ref(1),
selectedAnimation: ref(0),
animationProgress: ref(0),
animationDuration: ref(0),
loading: ref(false),
loadingMessage: ref(''),
initializeLoad3d: vi.fn(),
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
handleStartRecording: vi.fn(),
handleStopRecording: vi.fn(),
handleExportRecording: vi.fn(),
handleClearRecording: vi.fn(),
handleSeek: vi.fn(),
handleBackgroundImageUpdate: vi.fn(),
handleHDRIFileUpdate: vi.fn(),
handleExportModel: vi.fn(),
handleModelDrop: vi.fn(),
handleToggleGizmo: vi.fn(),
handleSetGizmoMode: vi.fn(),
handleResetGizmoTransform: vi.fn(),
handleFitToViewer: vi.fn(),
cleanup: vi.fn()
}
}
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => load3dState.current
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: settingGetMock })
}))
vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: resolveNodeMock
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: { fitToViewer: 'Fit to viewer' }
}
}
})
type RenderOptions = {
widget?: unknown
nodeId?: number | string
stateOverrides?: Partial<ReturnType<typeof buildLoad3dStub>>
enable3DViewer?: boolean
}
const MOCK_NODE = { id: 'node', type: 'Load3D' }
function renderLoad3D(options: RenderOptions = {}) {
const stub = buildLoad3dStub()
if (options.stateOverrides) {
Object.assign(stub, options.stateOverrides)
}
load3dState.current = stub
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.3DViewerEnable'
? (options.enable3DViewer ?? false)
: undefined
)
return {
...render(Load3D, {
props: {
widget: (options.widget ?? {
node: MOCK_NODE
}) as unknown as ComponentWidget<string[]>,
nodeId: options.nodeId
},
global: {
plugins: [i18n],
stubs: {
Load3DControls: {
name: 'Load3DControls',
template: '<div data-testid="load3d-controls" />'
},
Load3DScene: {
name: 'Load3DScene',
template: '<div data-testid="load3d-scene" />'
},
AnimationControls: {
name: 'AnimationControls',
template: '<div data-testid="animation-controls" />'
},
RecordingControls: {
name: 'RecordingControls',
template: '<div data-testid="recording-controls" />'
},
ViewerControls: {
name: 'ViewerControls',
template: '<div data-testid="viewer-controls" />'
},
Button: {
name: 'Button',
props: ['ariaLabel'],
template:
'<button type="button" :aria-label="ariaLabel"><slot /></button>'
}
},
directives: {
tooltip: () => {}
}
}
}),
stub
}
}
describe('Load3D', () => {
beforeEach(() => {
vi.clearAllMocks()
load3dState.current = null
})
describe('node resolution', () => {
it('uses widget.node when the widget is a ComponentWidget', () => {
renderLoad3D({ widget: { node: MOCK_NODE } })
expect(screen.getByTestId('load3d-scene')).toBeInTheDocument()
expect(resolveNodeMock).not.toHaveBeenCalled()
})
it('falls back to resolveNode(nodeId) when the widget lacks a node', async () => {
resolveNodeMock.mockReturnValue(MOCK_NODE)
renderLoad3D({ widget: {}, nodeId: 42 })
expect(resolveNodeMock).toHaveBeenCalledWith(42)
expect(await screen.findByTestId('load3d-scene')).toBeInTheDocument()
})
it('does not render Load3DScene when no node can be resolved', async () => {
resolveNodeMock.mockReturnValue(null)
renderLoad3D({ widget: {}, nodeId: 99 })
await Promise.resolve()
expect(screen.queryByTestId('load3d-scene')).not.toBeInTheDocument()
})
})
describe('capability-driven chrome', () => {
it('shows the fit-to-viewer button when canFitToViewer is true', () => {
renderLoad3D({ stateOverrides: { canFitToViewer: ref(true) } })
expect(
screen.getByRole('button', { name: 'Fit to viewer' })
).toBeInTheDocument()
})
it('hides the fit-to-viewer button when canFitToViewer is false', () => {
renderLoad3D({ stateOverrides: { canFitToViewer: ref(false) } })
expect(
screen.queryByRole('button', { name: 'Fit to viewer' })
).not.toBeInTheDocument()
})
it('invokes handleFitToViewer when the fit button is clicked', async () => {
const { stub } = renderLoad3D()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Fit to viewer' }))
expect(stub.handleFitToViewer).toHaveBeenCalledOnce()
})
})
describe('viewer controls', () => {
it('renders ViewerControls when the 3D viewer setting is enabled', () => {
renderLoad3D({ enable3DViewer: true })
expect(screen.getByTestId('viewer-controls')).toBeInTheDocument()
})
it('hides ViewerControls when the 3D viewer setting is disabled', () => {
renderLoad3D({ enable3DViewer: false })
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
})
it('hides ViewerControls when there is no node even if the setting is on', () => {
resolveNodeMock.mockReturnValue(null)
renderLoad3D({ widget: {}, nodeId: 1, enable3DViewer: true })
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
})
})
describe('recording controls', () => {
it('renders RecordingControls in regular (non-preview) mode', () => {
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
})
it('hides RecordingControls in preview mode', () => {
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
})
})
describe('animation controls', () => {
it('renders AnimationControls when animations are present', () => {
renderLoad3D({
stateOverrides: {
animations: ref([{ name: 'idle', index: 0 }])
}
})
expect(screen.getByTestId('animation-controls')).toBeInTheDocument()
})
it('hides AnimationControls when the animation list is empty', () => {
renderLoad3D()
expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument()
})
})
})

View File

@@ -22,8 +22,10 @@
v-model:model-config="modelConfig"
v-model:camera-config="cameraConfig"
v-model:light-config="lightConfig"
:is-splat-model="isSplatModel"
:is-ply-model="isPlyModel"
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@@ -43,7 +45,10 @@
@seek="handleSeek"
/>
</div>
<div class="pointer-events-auto absolute top-12 right-2 z-20">
<div
v-if="canFitToViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
>
<div class="flex flex-col rounded-lg bg-backdrop/30">
<Button
v-tooltip.left="{
@@ -138,8 +143,11 @@ const {
// other state
isRecording,
isPreview,
isSplatModel,
isPlyModel,
canFitToViewer,
canUseGizmo,
canUseLighting,
canExport,
materialModes,
hasSkeleton,
hasRecording,
recordingDuration,

View File

@@ -0,0 +1,404 @@
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 Load3DControls from '@/components/load3d/Load3DControls.vue'
import type {
CameraConfig,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
vi.mock('@/composables/useDismissableOverlay', () => ({
useDismissableOverlay: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
menu: { showMenu: 'Show menu' },
load3d: {
scene: 'Scene',
model: 'Model',
camera: 'Camera',
light: 'Light',
gizmo: { label: 'Gizmo' },
export: 'Export'
}
}
}
})
const childStubs = {
SceneControls: {
name: 'SceneControls',
emits: ['update-background-image'],
template: `<div data-testid="scene-controls">
<button data-testid="scene-emit-bg" @click="$emit('update-background-image', null)" />
</div>`
},
ModelControls: {
name: 'ModelControls',
template: '<div data-testid="model-controls" />'
},
CameraControls: {
name: 'CameraControls',
template: '<div data-testid="camera-controls" />'
},
LightControls: {
name: 'LightControls',
template: '<div data-testid="light-controls" />'
},
HDRIControls: {
name: 'HDRIControls',
emits: ['update-hdri-file'],
template: `<div data-testid="hdri-controls">
<button data-testid="hdri-emit-file" @click="$emit('update-hdri-file', null)" />
</div>`
},
ExportControls: {
name: 'ExportControls',
emits: ['export-model'],
template: `<div data-testid="export-controls">
<button data-testid="export-emit-glb" @click="$emit('export-model', 'glb')" />
</div>`
},
GizmoControls: {
name: 'GizmoControls',
emits: ['toggle-gizmo', 'set-gizmo-mode', 'reset-gizmo-transform'],
template: `<div data-testid="gizmo-controls">
<button data-testid="gizmo-emit-toggle" @click="$emit('toggle-gizmo', true)" />
<button data-testid="gizmo-emit-mode" @click="$emit('set-gizmo-mode', 'rotate')" />
<button data-testid="gizmo-emit-reset" @click="$emit('reset-gizmo-transform')" />
</div>`
}
}
const defaultSceneConfig: SceneConfig = {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
}
const defaultModelConfig: ModelConfig = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
const defaultCameraConfig: CameraConfig = {
cameraType: 'perspective',
fov: 75
}
const defaultLightConfig: LightConfig = {
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
}
type RenderProps = {
sceneConfig?: SceneConfig
modelConfig?: ModelConfig
cameraConfig?: CameraConfig
lightConfig?: LightConfig
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
onUpdateBackgroundImage?: (file: File | null) => void
onExportModel?: (format: string) => void
onUpdateHdriFile?: (file: File | null) => void
onToggleGizmo?: (enabled: boolean) => void
onSetGizmoMode?: (mode: string) => void
onResetGizmoTransform?: () => void
}
function renderControls(overrides: RenderProps = {}) {
const result = render(Load3DControls, {
props: {
sceneConfig: defaultSceneConfig,
modelConfig: defaultModelConfig,
cameraConfig: defaultCameraConfig,
lightConfig: defaultLightConfig,
canUseGizmo: true,
canUseLighting: true,
canExport: true,
materialModes: ['original', 'normal', 'wireframe'],
hasSkeleton: false,
...overrides
},
global: {
plugins: [i18n],
stubs: childStubs,
directives: {
tooltip: () => {}
}
}
})
return { ...result, user: userEvent.setup() }
}
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Show menu' }))
}
describe('Load3DControls', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('category menu', () => {
it('renders SceneControls by default', () => {
renderControls()
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
})
it('keeps the category menu closed until the trigger is clicked', async () => {
const { user } = renderControls()
expect(
screen.queryByRole('button', { name: 'Scene' })
).not.toBeInTheDocument()
await openMenu(user)
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
})
it('shows every category when all capabilities are enabled', async () => {
const { user } = renderControls()
await openMenu(user)
for (const label of [
'Scene',
'Model',
'Camera',
'Light',
'Gizmo',
'Export'
]) {
expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
}
})
it('omits the light category when canUseLighting is false', async () => {
const { user } = renderControls({ canUseLighting: false })
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Light' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
})
it('omits the gizmo category when canUseGizmo is false', async () => {
const { user } = renderControls({ canUseGizmo: false })
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Gizmo' })
).not.toBeInTheDocument()
})
it('omits the export category when canExport is false', async () => {
const { user } = renderControls({ canExport: false })
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Export' })
).not.toBeInTheDocument()
})
it('selecting a category closes the menu and swaps the visible control', async () => {
const { user } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Model' }))
expect(
screen.queryByRole('button', { name: 'Scene' })
).not.toBeInTheDocument()
expect(screen.getByTestId('model-controls')).toBeInTheDocument()
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
})
describe('control visibility', () => {
async function selectCategory(
user: ReturnType<typeof userEvent.setup>,
label: string
) {
await openMenu(user)
await user.click(screen.getByRole('button', { name: label }))
}
it.each([
['Model', 'model-controls'],
['Camera', 'camera-controls']
])('%s category renders only %s', async (label, testId) => {
const { user } = renderControls()
await selectCategory(user, label)
expect(screen.getByTestId(testId)).toBeInTheDocument()
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
it('Light category renders both LightControls and HDRIControls', async () => {
const { user } = renderControls()
await selectCategory(user, 'Light')
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
expect(screen.getByTestId('hdri-controls')).toBeInTheDocument()
})
it('Gizmo category renders GizmoControls', async () => {
const { user } = renderControls()
await selectCategory(user, 'Gizmo')
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
})
it('Export category renders ExportControls', async () => {
const { user } = renderControls()
await selectCategory(user, 'Export')
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
})
it('hides all controls when the corresponding v-model is undefined', () => {
renderControls({
sceneConfig: undefined,
modelConfig: undefined,
cameraConfig: undefined,
lightConfig: undefined
})
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
})
describe('capability desync handling', () => {
it('hides the active panel and resets to scene when its capability is dropped at runtime', async () => {
const { user, rerender } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Light' }))
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
await rerender({ canUseLighting: false })
expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument()
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Light' })
).not.toBeInTheDocument()
})
it.each([
['Gizmo', 'gizmo-controls', 'canUseGizmo' as const],
['Export', 'export-controls', 'canExport' as const]
])(
'hides the %s panel when its capability flips off at runtime',
async (label, testId, capabilityProp) => {
const { user, rerender } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: label }))
expect(screen.getByTestId(testId)).toBeInTheDocument()
await rerender({ [capabilityProp]: false })
expect(screen.queryByTestId(testId)).not.toBeInTheDocument()
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
}
)
it('does not reset activeCategory when capabilities change but the active one is still available', async () => {
const { user, rerender } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Camera' }))
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
await rerender({ canUseLighting: false, canUseGizmo: false })
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
})
describe('event forwarding', () => {
it('forwards updateBackgroundImage from SceneControls', async () => {
const onUpdateBackgroundImage = vi.fn()
const { user } = renderControls({ onUpdateBackgroundImage })
await user.click(screen.getByTestId('scene-emit-bg'))
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
})
it('forwards exportModel from ExportControls', async () => {
const onExportModel = vi.fn()
const { user } = renderControls({ onExportModel })
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(screen.getByTestId('export-emit-glb'))
expect(onExportModel).toHaveBeenCalledWith('glb')
})
it('forwards updateHdriFile from HDRIControls', async () => {
const onUpdateHdriFile = vi.fn()
const { user } = renderControls({ onUpdateHdriFile })
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Light' }))
await user.click(screen.getByTestId('hdri-emit-file'))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
it('forwards gizmo events from GizmoControls', async () => {
const onToggleGizmo = vi.fn()
const onSetGizmoMode = vi.fn()
const onResetGizmoTransform = vi.fn()
const { user } = renderControls({
onToggleGizmo,
onSetGizmoMode,
onResetGizmoTransform
})
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
await user.click(screen.getByTestId('gizmo-emit-toggle'))
await user.click(screen.getByTestId('gizmo-emit-mode'))
await user.click(screen.getByTestId('gizmo-emit-reset'))
expect(onToggleGizmo).toHaveBeenCalledWith(true)
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
expect(onResetGizmoTransform).toHaveBeenCalledOnce()
})
})
})

View File

@@ -63,8 +63,7 @@
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
v-model:show-skeleton="modelConfig!.showSkeleton"
:hide-material-mode="isSplatModel"
:is-ply-model="isPlyModel"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
/>
@@ -105,7 +104,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
@@ -120,18 +119,23 @@ import type {
CameraConfig,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const {
isSplatModel = false,
isPlyModel = false,
canUseGizmo = true,
canUseLighting = true,
canExport = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
isSplatModel?: boolean
isPlyModel?: boolean
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()
@@ -163,13 +167,23 @@ const categoryLabels: Record<string, string> = {
}
const availableCategories = computed(() => {
if (isSplatModel) {
return ['scene', 'model', 'camera']
}
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
const categories = ['scene', 'model', 'camera']
if (canUseLighting) categories.push('light')
if (canUseGizmo) categories.push('gizmo')
if (canExport) categories.push('export')
return categories
})
watch(
availableCategories,
(categories) => {
if (!categories.includes(activeCategory.value)) {
activeCategory.value = 'scene'
}
},
{ immediate: true }
)
const showSceneControls = computed(
() => activeCategory.value === 'scene' && !!sceneConfig.value
)
@@ -181,13 +195,16 @@ const showCameraControls = computed(
)
const showLightControls = computed(
() =>
canUseLighting &&
activeCategory.value === 'light' &&
!!lightConfig.value &&
!!modelConfig.value
)
const showExportControls = computed(() => activeCategory.value === 'export')
const showExportControls = computed(
() => canExport && activeCategory.value === 'export'
)
const showGizmoControls = computed(
() => activeCategory.value === 'gizmo' && !!modelConfig.value
() => canUseGizmo && activeCategory.value === 'gizmo' && !!modelConfig.value
)
const toggleMenu = () => {

View File

@@ -0,0 +1,153 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { ref } from 'vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
const dragState = vi.hoisted(() => ({
isDragging: null as Ref<boolean> | null,
dragMessage: null as Ref<string> | null,
handleDragOver: vi.fn(),
handleDragLeave: vi.fn(),
handleDrop: vi.fn(),
capturedOptions: null as {
onModelDrop?: (file: File) => Promise<void>
disabled?: { value?: boolean } | boolean
} | null
}))
vi.mock('@/composables/useLoad3dDrag', () => ({
useLoad3dDrag: (options: unknown) => {
dragState.capturedOptions = options as typeof dragState.capturedOptions
return {
isDragging: dragState.isDragging!,
dragMessage: dragState.dragMessage!,
handleDragOver: dragState.handleDragOver,
handleDragLeave: dragState.handleDragLeave,
handleDrop: dragState.handleDrop
}
}
}))
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
default: {
name: 'LoadingOverlayStub',
props: ['loading', 'loadingMessage'],
template: `
<div data-testid="loading-overlay">
<span v-if="loading">{{ loadingMessage }}</span>
</div>
`
}
}))
type RenderOpts = {
loading?: boolean
loadingMessage?: string
isPreview?: boolean
onModelDrop?: (file: File) => void | Promise<void>
initializeLoad3d?: (container: HTMLElement) => Promise<void>
cleanup?: () => void
}
function renderComponent(opts: RenderOpts = {}) {
const initializeLoad3d =
opts.initializeLoad3d ?? vi.fn().mockResolvedValue(undefined)
const cleanup = opts.cleanup ?? vi.fn()
const utils = render(Load3DScene, {
props: {
initializeLoad3d,
cleanup,
loading: opts.loading ?? false,
loadingMessage: opts.loadingMessage ?? '',
onModelDrop: opts.onModelDrop,
isPreview: opts.isPreview ?? false
}
})
return { ...utils, initializeLoad3d, cleanup }
}
describe('Load3DScene', () => {
beforeEach(() => {
dragState.isDragging = ref(false)
dragState.dragMessage = ref('')
dragState.handleDragOver.mockReset()
dragState.handleDragLeave.mockReset()
dragState.handleDrop.mockReset()
dragState.capturedOptions = null
})
it('renders the loading overlay child', () => {
renderComponent()
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
})
it('forwards loading + loadingMessage props to the overlay', () => {
renderComponent({ loading: true, loadingMessage: 'Loading model…' })
expect(screen.getByText('Loading model…')).toBeInTheDocument()
})
it('calls initializeLoad3d with the container element on mount', async () => {
const initializeLoad3d = vi.fn().mockResolvedValue(undefined)
renderComponent({ initializeLoad3d })
expect(initializeLoad3d).toHaveBeenCalledOnce()
expect(initializeLoad3d.mock.calls[0][0]).toBeInstanceOf(HTMLElement)
})
it('calls cleanup when unmounted', () => {
const cleanup = vi.fn()
const { unmount } = renderComponent({ cleanup })
unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('does not render the drag overlay when not dragging', () => {
dragState.isDragging!.value = false
dragState.dragMessage!.value = 'Drop'
renderComponent()
expect(screen.queryByText('Drop')).not.toBeInTheDocument()
})
it('renders the drag overlay with the drag message while dragging in non-preview mode', () => {
dragState.isDragging!.value = true
dragState.dragMessage!.value = 'Drop to load model'
renderComponent({ isPreview: false })
expect(screen.getByText('Drop to load model')).toBeInTheDocument()
})
it('hides the drag overlay even while dragging when in preview mode', () => {
dragState.isDragging!.value = true
dragState.dragMessage!.value = 'Drop to load model'
renderComponent({ isPreview: true })
expect(screen.queryByText('Drop to load model')).not.toBeInTheDocument()
})
it('forwards a dropped file through useLoad3dDrag to the onModelDrop prop', async () => {
const onModelDrop = vi.fn()
renderComponent({ onModelDrop })
const file = new File(['m'], 'model.glb')
await dragState.capturedOptions!.onModelDrop!(file)
expect(onModelDrop).toHaveBeenCalledWith(file)
})
it('does not throw when a file is dropped without an onModelDrop handler', async () => {
renderComponent({ onModelDrop: undefined })
const file = new File(['m'], 'model.glb')
await expect(
dragState.capturedOptions!.onModelDrop!(file)
).resolves.toBeUndefined()
})
})

View File

@@ -0,0 +1,361 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
class NoopMutationObserver {
observe() {}
disconnect() {}
takeRecords(): MutationRecord[] {
return []
}
}
const {
viewerState,
dragState,
capturedDragOptions,
dialogCloseMock,
serviceSourceLoad3d,
getLoad3dAsyncMock
} = vi.hoisted(() => ({
viewerState: {
current: null as ReturnType<typeof buildViewerStub> | null
},
dragState: {
current: null as ReturnType<typeof buildDragStub> | null
},
capturedDragOptions: {
current: null as { onModelDrop?: (file: File) => Promise<void> } | null
},
dialogCloseMock: vi.fn(),
serviceSourceLoad3d: {
current: null as unknown
},
getLoad3dAsyncMock: vi.fn()
}))
function buildViewerStub() {
return {
backgroundColor: ref('#282828'),
showGrid: ref(true),
cameraType: ref('perspective'),
fov: ref(75),
lightIntensity: ref(1),
backgroundImage: ref(''),
hasBackgroundImage: ref(false),
backgroundRenderMode: ref('tiled'),
upDirection: ref('original'),
materialMode: ref('original'),
gizmoEnabled: ref(false),
gizmoMode: ref('translate'),
isPreview: ref(false),
isStandaloneMode: ref(false),
canUseGizmo: ref(true),
canUseLighting: ref(true),
canExport: ref(true),
materialModes: ref(['original', 'normal', 'wireframe']),
animations: ref<Array<{ name: string; index: number }>>([]),
playing: ref(false),
selectedSpeed: ref(1),
selectedAnimation: ref(0),
animationProgress: ref(0),
animationDuration: ref(0),
initializeViewer: vi.fn().mockResolvedValue(undefined),
initializeStandaloneViewer: vi.fn().mockResolvedValue(undefined),
exportModel: vi.fn(),
handleResize: vi.fn(),
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
restoreInitialState: vi.fn(),
refreshViewport: vi.fn(),
handleBackgroundImageUpdate: vi.fn(),
handleModelDrop: vi.fn().mockResolvedValue(undefined),
handleSeek: vi.fn(),
resetGizmoTransform: vi.fn()
}
}
function buildDragStub() {
return {
isDragging: ref(false),
dragMessage: ref(''),
handleDragOver: vi.fn(),
handleDragLeave: vi.fn(),
handleDrop: vi.fn()
}
}
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: () => viewerState.current
}))
vi.mock('@/composables/useLoad3dDrag', () => ({
useLoad3dDrag: (opts: { onModelDrop?: (file: File) => Promise<void> }) => {
capturedDragOptions.current = opts
return dragState.current
}
}))
vi.mock('@/services/load3dService', () => ({
useLoad3dService: () => ({
getOrCreateViewerSync: () => viewerState.current,
getLoad3dAsync: getLoad3dAsyncMock
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ closeDialog: dialogCloseMock })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { cancel: 'Cancel' }
}
}
})
type RenderOptions = {
node?: LGraphNode
modelUrl?: string
viewerOverrides?: Partial<ReturnType<typeof buildViewerStub>>
dragOverrides?: Partial<ReturnType<typeof buildDragStub>>
}
const MOCK_NODE = createMockLGraphNode({ id: 'node-1', type: 'Load3D' })
async function renderViewerContent(options: RenderOptions = {}) {
const viewerStub = buildViewerStub()
if (options.viewerOverrides) {
Object.assign(viewerStub, options.viewerOverrides)
}
viewerState.current = viewerStub
const dragStub = buildDragStub()
if (options.dragOverrides) {
Object.assign(dragStub, options.dragOverrides)
}
dragState.current = dragStub
getLoad3dAsyncMock.mockResolvedValue(serviceSourceLoad3d.current)
const result = render(Load3dViewerContent, {
props: {
node: options.node,
modelUrl: options.modelUrl
},
global: {
plugins: [i18n],
stubs: {
AnimationControls: {
name: 'AnimationControls',
template: '<div data-testid="animation-controls" />'
},
CameraControls: {
name: 'CameraControls',
template: '<div data-testid="camera-controls" />'
},
ExportControls: {
name: 'ExportControls',
template: '<div data-testid="export-controls" />'
},
GizmoControls: {
name: 'GizmoControls',
template: '<div data-testid="gizmo-controls" />'
},
LightControls: {
name: 'LightControls',
template: '<div data-testid="light-controls" />'
},
ModelControls: {
name: 'ModelControls',
template: '<div data-testid="model-controls" />'
},
SceneControls: {
name: 'SceneControls',
template: '<div data-testid="scene-controls" />'
},
Button: {
name: 'Button',
template: '<button type="button"><slot /></button>'
}
}
}
})
return {
...result,
viewer: viewerStub,
drag: dragStub,
user: userEvent.setup()
}
}
describe('Load3dViewerContent', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('MutationObserver', NoopMutationObserver)
viewerState.current = null
dragState.current = null
capturedDragOptions.current = null
serviceSourceLoad3d.current = null
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('initialization', () => {
it('invokes initializeStandaloneViewer when a modelUrl is provided without a node', async () => {
const { viewer } = await renderViewerContent({
modelUrl: 'api/view?filename=cube.glb'
})
await vi.waitFor(() =>
expect(viewer.initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'api/view?filename=cube.glb'
)
)
expect(viewer.initializeViewer).not.toHaveBeenCalled()
})
it('invokes initializeViewer with the source load3d when a node is provided', async () => {
const source = { id: 'source-load3d' }
serviceSourceLoad3d.current = source
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
await vi.waitFor(() =>
expect(viewer.initializeViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
source
)
)
expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE)
expect(viewer.initializeStandaloneViewer).not.toHaveBeenCalled()
})
it('skips initializeViewer if the source load3d cannot be resolved', async () => {
serviceSourceLoad3d.current = null
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
await vi.waitFor(() =>
expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE)
)
expect(viewer.initializeViewer).not.toHaveBeenCalled()
})
})
describe('capability gating', () => {
it('hides LightControls when canUseLighting is false', async () => {
await renderViewerContent({
node: MOCK_NODE,
viewerOverrides: { canUseLighting: ref(false) }
})
expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument()
})
it('hides GizmoControls when canUseGizmo is false', async () => {
await renderViewerContent({
node: MOCK_NODE,
viewerOverrides: { canUseGizmo: ref(false) }
})
expect(screen.queryByTestId('gizmo-controls')).not.toBeInTheDocument()
})
it('hides ExportControls when canExport is false', async () => {
await renderViewerContent({
node: MOCK_NODE,
viewerOverrides: { canExport: ref(false) }
})
expect(screen.queryByTestId('export-controls')).not.toBeInTheDocument()
})
it('renders all capability-gated controls when all flags are true', async () => {
await renderViewerContent({ node: MOCK_NODE })
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
})
})
describe('animation controls', () => {
it('hides AnimationControls when the animation list is empty', async () => {
await renderViewerContent({ node: MOCK_NODE })
expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument()
})
it('shows AnimationControls when animations are present', async () => {
await renderViewerContent({
node: MOCK_NODE,
viewerOverrides: {
animations: ref([{ name: 'idle', index: 0 }])
}
})
expect(screen.getByTestId('animation-controls')).toBeInTheDocument()
})
})
describe('drag overlay', () => {
it('is hidden by default', async () => {
await renderViewerContent({ node: MOCK_NODE })
expect(screen.queryByText(/drag/i)).not.toBeInTheDocument()
})
it('renders the drag message when useLoad3dDrag reports dragging', async () => {
await renderViewerContent({
node: MOCK_NODE,
dragOverrides: {
isDragging: ref(true),
dragMessage: ref('Drop to load')
}
})
expect(screen.getByText('Drop to load')).toBeInTheDocument()
})
})
describe('drag integration', () => {
it('routes a dropped file through useLoad3dDrag back to viewer.handleModelDrop', async () => {
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
const file = new File(['cube'], 'cube.glb')
await capturedDragOptions.current!.onModelDrop!(file)
expect(viewer.handleModelDrop).toHaveBeenCalledWith(file)
})
})
describe('cancel button', () => {
it('closes the dialog in node mode and restores initial viewer state', async () => {
const { user, viewer } = await renderViewerContent({ node: MOCK_NODE })
await user.click(screen.getByRole('button', { name: /Cancel/ }))
expect(viewer.restoreInitialState).toHaveBeenCalledOnce()
expect(dialogCloseMock).toHaveBeenCalledOnce()
})
it('closes the dialog in standalone mode without touching initial state', async () => {
const { user, viewer } = await renderViewerContent({
modelUrl: 'api/view?filename=cube.glb'
})
await user.click(screen.getByRole('button', { name: /Cancel/ }))
expect(viewer.restoreInitialState).not.toHaveBeenCalled()
expect(dialogCloseMock).toHaveBeenCalledOnce()
})
})
})

View File

@@ -56,8 +56,7 @@
<ModelControls
v-model:up-direction="viewer.upDirection.value"
v-model:material-mode="viewer.materialMode.value"
:hide-material-mode="viewer.isSplatModel.value"
:is-ply-model="viewer.isPlyModel.value"
:material-modes="viewer.materialModes.value"
/>
</div>
@@ -68,13 +67,13 @@
/>
</div>
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
<div v-if="viewer.canUseLighting.value" class="space-y-4 p-2">
<LightControls
v-model:light-intensity="viewer.lightIntensity.value"
/>
</div>
<div class="space-y-4 p-2">
<div v-if="viewer.canUseGizmo.value" class="space-y-4 p-2">
<GizmoControls
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
v-model:gizmo-mode="viewer.gizmoMode.value"
@@ -82,7 +81,7 @@
/>
</div>
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
<ExportControls @export-model="viewer.exportModel" />
</div>
</div>

View File

@@ -0,0 +1,205 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
}
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { playPause: 'Play / pause' } } }
})
type Animation = { name: string; index: number }
type RenderOpts = {
animations?: Animation[]
playing?: boolean
selectedSpeed?: number
selectedAnimation?: number
animationProgress?: number
animationDuration?: number
onSeek?: (progress: number) => void
}
function renderComponent(opts: RenderOpts = {}) {
const animations = ref<Animation[]>(opts.animations ?? [])
const playing = ref<boolean>(opts.playing ?? false)
const selectedSpeed = ref<number>(opts.selectedSpeed ?? 1)
const selectedAnimation = ref<number>(opts.selectedAnimation ?? 0)
const animationProgress = ref<number>(opts.animationProgress ?? 0)
const animationDuration = ref<number>(opts.animationDuration ?? 10)
const utils = render(AnimationControls, {
props: {
animations: animations.value,
'onUpdate:animations': (v: Animation[] | undefined) => {
if (v) animations.value = v
},
playing: playing.value,
'onUpdate:playing': (v: boolean | undefined) => {
if (v !== undefined) playing.value = v
},
selectedSpeed: selectedSpeed.value,
'onUpdate:selectedSpeed': (v: number | undefined) => {
if (v !== undefined) selectedSpeed.value = v
},
selectedAnimation: selectedAnimation.value,
'onUpdate:selectedAnimation': (v: number | undefined) => {
if (v !== undefined) selectedAnimation.value = v
},
animationProgress: animationProgress.value,
'onUpdate:animationProgress': (v: number | undefined) => {
if (v !== undefined) animationProgress.value = v
},
animationDuration: animationDuration.value,
'onUpdate:animationDuration': (v: number | undefined) => {
if (v !== undefined) animationDuration.value = v
},
onSeek: opts.onSeek
},
global: { plugins: [i18n] }
})
return {
...utils,
animations,
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
user: userEvent.setup()
}
}
describe('AnimationControls', () => {
it('renders nothing when the animation list is empty', () => {
renderComponent({ animations: [] })
expect(
screen.queryByRole('button', { name: 'Play / pause' })
).not.toBeInTheDocument()
})
it('renders the play / speed / track / progress widgets when animations are present', () => {
renderComponent({
animations: [
{ name: 'idle', index: 0 },
{ name: 'walk', index: 1 }
]
})
expect(
screen.getByRole('button', { name: 'Play / pause' })
).toBeInTheDocument()
expect(screen.getAllByRole('combobox')).toHaveLength(2)
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('flips playing to true via v-model when starting from a paused state', async () => {
const { user, playing } = renderComponent({
animations: [{ name: 'idle', index: 0 }],
playing: false
})
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
expect(playing.value).toBe(true)
})
it('flips playing to false via v-model when starting from a playing state', async () => {
const { user, playing } = renderComponent({
animations: [{ name: 'idle', index: 0 }],
playing: true
})
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
expect(playing.value).toBe(false)
})
it('updates animationProgress and emits seek with the new progress when the slider moves', () => {
const onSeek = vi.fn()
const { animationProgress } = renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationProgress: 0,
onSeek
})
const slider = screen.getByRole('slider') as HTMLInputElement
slider.value = '37.5'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(animationProgress.value).toBe(37.5)
expect(onSeek).toHaveBeenCalledWith(37.5)
})
it('formats the time display under one minute as Ns', () => {
renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationDuration: 30,
animationProgress: 50 // half of 30s = 15s
})
expect(screen.getByText('15.0s / 30.0s')).toBeInTheDocument()
})
it('formats the time display over one minute as M:SS.S', () => {
renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationDuration: 90,
animationProgress: 50 // half of 90s = 45s, total 1:30.0
})
expect(screen.getByText('45.0s / 1:30.0')).toBeInTheDocument()
})
it('shows 0s for currentTime when animationDuration is 0', () => {
renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationDuration: 0,
animationProgress: 50
})
expect(screen.getByText('0.0s / 0.0s')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({
default: {
name: 'PopupSlider',
props: ['tooltipText', 'modelValue'],
template: '<div data-testid="popup-slider">{{ tooltipText }}</div>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { load3d: { switchCamera: 'Switch camera', fov: 'FOV' } }
}
})
function renderComponent(initial: { type?: CameraType; fov?: number } = {}) {
const cameraType = ref<CameraType>(initial.type ?? 'perspective')
const fov = ref<number>(initial.fov ?? 75)
const utils = render(CameraControls, {
props: {
cameraType: cameraType.value,
'onUpdate:cameraType': (v: CameraType | undefined) => {
if (v) cameraType.value = v
},
fov: fov.value,
'onUpdate:fov': (v: number | undefined) => {
if (v !== undefined) fov.value = v
}
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, cameraType, fov, user: userEvent.setup() }
}
describe('CameraControls', () => {
it('renders the switch-camera button', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Switch camera' })
).toBeInTheDocument()
})
it('shows the FOV PopupSlider only for the perspective camera', () => {
renderComponent({ type: 'perspective' })
expect(screen.getByTestId('popup-slider')).toBeInTheDocument()
})
it('hides the FOV PopupSlider for the orthographic camera', () => {
renderComponent({ type: 'orthographic' })
expect(screen.queryByTestId('popup-slider')).not.toBeInTheDocument()
})
it('toggles cameraType from perspective to orthographic when the button is clicked', async () => {
const { user, cameraType } = renderComponent({ type: 'perspective' })
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
expect(cameraType.value).toBe('orthographic')
})
it('toggles cameraType from orthographic to perspective when the button is clicked', async () => {
const { user, cameraType } = renderComponent({ type: 'orthographic' })
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
expect(cameraType.value).toBe('perspective')
})
})

View File

@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { load3d: { exportModel: 'Export model' } }
}
})
function renderComponent(onExportModel?: (format: string) => void) {
const utils = render(ExportControls, {
props: { onExportModel },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, user: userEvent.setup() }
}
describe('ExportControls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('renders the trigger button without exposing the format list initially', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Export model' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'GLB' })
).not.toBeInTheDocument()
})
it('reveals all three export format buttons when the trigger is clicked', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Export model' }))
for (const label of ['GLB', 'OBJ', 'STL']) {
expect(screen.getByRole('button', { name: label })).toBeVisible()
}
})
it('emits exportModel with the chosen format and hides the popup', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel)
await user.click(screen.getByRole('button', { name: 'Export model' }))
await user.click(screen.getByRole('button', { name: 'OBJ' }))
expect(onExportModel).toHaveBeenCalledWith('obj')
expect(
screen.queryByRole('button', { name: 'GLB' })
).not.toBeInTheDocument()
})
it('hides the popup when a click happens outside the trigger', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Export model' }))
expect(screen.getByRole('button', { name: 'GLB' })).toBeVisible()
await user.click(document.body)
expect(
screen.queryByRole('button', { name: 'GLB' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,197 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden file input has no role/label, queried by selector */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
const addAlert = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
hdri: {
label: 'HDRI',
uploadFile: 'Upload HDRI',
changeFile: 'Change HDRI',
showAsBackground: 'Show as background',
removeFile: 'Remove HDRI'
}
},
toastMessages: { unsupportedHDRIFormat: 'Unsupported HDRI format' }
}
}
})
const defaultConfig: HDRIConfig = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
type RenderOpts = {
config?: HDRIConfig
hasBackgroundImage?: boolean
onUpdateHdriFile?: (file: File | null) => void
}
function renderComponent(opts: RenderOpts = {}) {
const config = ref<HDRIConfig>(opts.config ?? { ...defaultConfig })
const utils = render(HDRIControls, {
props: {
hdriConfig: config.value,
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
if (v) config.value = v
},
hasBackgroundImage: opts.hasBackgroundImage ?? false,
onUpdateHdriFile: opts.onUpdateHdriFile
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, config, user: userEvent.setup() }
}
describe('HDRIControls', () => {
beforeEach(() => {
addAlert.mockClear()
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('initial render', () => {
it('renders the upload button when no HDRI is loaded', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Upload HDRI' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'HDRI' })
).not.toBeInTheDocument()
})
it('renders the change / toggle / show-as-bg / remove buttons when an HDRI is loaded', () => {
renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
expect(
screen.getByRole('button', { name: 'Change HDRI' })
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'HDRI' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Show as background' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Remove HDRI' })
).toBeInTheDocument()
})
it('hides the entire control when a background image is set and no HDRI is loaded', () => {
const { container } = renderComponent({
hasBackgroundImage: true,
config: { ...defaultConfig, hdriPath: '' }
})
expect(container.querySelector('button')).toBeNull()
})
it('still renders when a background image is set but an HDRI is loaded', () => {
renderComponent({
hasBackgroundImage: true,
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
expect(
screen.getByRole('button', { name: 'Change HDRI' })
).toBeInTheDocument()
})
})
describe('toggle buttons', () => {
it('flips enabled in the v-model when the HDRI button is clicked', async () => {
const { user, config } = renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
await user.click(screen.getByRole('button', { name: 'HDRI' }))
expect(config.value.enabled).toBe(true)
})
it('flips showAsBackground in the v-model when the show-as-background button is clicked', async () => {
const { user, config } = renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
await user.click(
screen.getByRole('button', { name: 'Show as background' })
)
expect(config.value.showAsBackground).toBe(true)
})
})
describe('file events', () => {
it('emits updateHdriFile(null) when the remove button is clicked', async () => {
const onUpdateHdriFile = vi.fn()
const { user } = renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' },
onUpdateHdriFile
})
await user.click(screen.getByRole('button', { name: 'Remove HDRI' }))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
it('emits updateHdriFile with the picked file when its extension is supported', async () => {
const onUpdateHdriFile = vi.fn()
const { container } = renderComponent({ onUpdateHdriFile })
const fileInput = container.querySelector(
'input[type="file"]'
) as HTMLInputElement
const file = new File(['hdri-data'], 'sky.hdr', {
type: 'application/octet-stream'
})
Object.defineProperty(fileInput, 'files', { value: [file] })
fileInput.dispatchEvent(new Event('change'))
expect(onUpdateHdriFile).toHaveBeenCalledWith(file)
expect(addAlert).not.toHaveBeenCalled()
})
it('rejects unsupported file extensions with a toast and no emit', async () => {
const onUpdateHdriFile = vi.fn()
const { container } = renderComponent({ onUpdateHdriFile })
const fileInput = container.querySelector(
'input[type="file"]'
) as HTMLInputElement
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(fileInput, 'files', { value: [file] })
fileInput.dispatchEvent(new Event('change'))
expect(onUpdateHdriFile).not.toHaveBeenCalled()
expect(addAlert).toHaveBeenCalledWith('Unsupported HDRI format')
})
})
})

View File

@@ -0,0 +1,193 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import type {
HDRIConfig,
MaterialMode
} from '@/extensions/core/load3d/interfaces'
const settingValues: Record<string, unknown> = {
'Comfy.Load3D.LightIntensityMaximum': 10,
'Comfy.Load3D.LightIntensityMinimum': 1,
'Comfy.Load3D.LightAdjustmentIncrement': 0.5
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settingValues[key]
})
}))
vi.mock('@/composables/useDismissableOverlay', () => ({
useDismissableOverlay: vi.fn()
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { load3d: { lightIntensity: 'Light intensity' } } }
})
type RenderOpts = {
lightIntensity?: number
materialMode?: MaterialMode
hdriConfig?: HDRIConfig
embedded?: boolean
}
function renderComponent(opts: RenderOpts = {}) {
const lightIntensity = ref<number>(opts.lightIntensity ?? 5)
const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original')
const hdriConfig = ref<HDRIConfig | undefined>(opts.hdriConfig)
const utils = render(LightControls, {
props: {
lightIntensity: lightIntensity.value,
'onUpdate:lightIntensity': (v: number | undefined) => {
if (v !== undefined) lightIntensity.value = v
},
materialMode: materialMode.value,
'onUpdate:materialMode': (v: MaterialMode | undefined) => {
if (v) materialMode.value = v
},
hdriConfig: hdriConfig.value,
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
hdriConfig.value = v
},
embedded: opts.embedded ?? false
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return {
...utils,
lightIntensity,
hdriConfig,
user: userEvent.setup()
}
}
describe('LightControls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
describe('material mode gating', () => {
it('renders the intensity control when materialMode is original', () => {
renderComponent({ materialMode: 'original' })
expect(
screen.getByRole('button', { name: 'Light intensity' })
).toBeInTheDocument()
})
it.each(['normal', 'wireframe'] as const)(
'hides the intensity control when materialMode is %s',
(mode) => {
renderComponent({ materialMode: mode })
expect(
screen.queryByRole('button', { name: 'Light intensity' })
).not.toBeInTheDocument()
}
)
})
describe('default (non-HDRI) mode', () => {
it('feeds the slider with the setting-store min / max / step', async () => {
const { user } = renderComponent({ lightIntensity: 5 })
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
expect(slider.min).toBe('1')
expect(slider.max).toBe('10')
expect(slider.step).toBe('0.5')
})
it('updates lightIntensity v-model when the slider changes', async () => {
const { user, lightIntensity } = renderComponent({ lightIntensity: 5 })
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
slider.value = '7.5'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(lightIntensity.value).toBe(7.5)
})
})
describe('HDRI active mode', () => {
const hdriConfig: HDRIConfig = {
enabled: true,
hdriPath: '/api/hdri/test.hdr',
showAsBackground: false,
intensity: 2
}
it('reads the slider min / max / step from the HDRI range (0..5 step 0.1)', async () => {
const { user } = renderComponent({ hdriConfig })
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
expect(slider.min).toBe('0')
expect(slider.max).toBe('5')
expect(slider.step).toBe('0.1')
})
it('writes back to hdriConfig.intensity instead of lightIntensity when the slider changes', async () => {
const {
user,
lightIntensity,
hdriConfig: cfg
} = renderComponent({
lightIntensity: 5,
hdriConfig
})
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
slider.value = '3.5'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(cfg.value?.intensity).toBe(3.5)
expect(lightIntensity.value).toBe(5) // unchanged
})
})
describe('embedded mode', () => {
it('renders the slider inline without the trigger button when embedded is true', () => {
renderComponent({ embedded: true })
expect(
screen.queryByRole('button', { name: 'Light intensity' })
).not.toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
})

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