Compare commits

..

21 Commits

Author SHA1 Message Date
dante01yoon
8d71b012e4 refactor(SnackbarToast): replace module singleton with Reka ToastProvider
Codex adversarial review (PR #11731) flagged the original
`useSnackbarToast` for owning UI state as raw module-level refs and a
manual `setTimeout`, which conflicts with the existing toast stack
(PrimeVue/GlobalToast/HoneyToast) and is unsafe under HMR, multiple
hosts, rapid back-to-back show() calls, and throwing action callbacks.

This rewrites the component on top of Reka's `ToastProvider` /
`ToastRoot` / `ToastAction` / `ToastClose` / `ToastViewport` primitives,
which already handle the queue, duration, hover/focus pause, swipe
dismiss, and SR announcement. State now lives in a `<SnackbarToastProvider>`
component scope, not at module load.

Changes:
- `useSnackbarToast()` is now an inject-based hook returning
  `{ show, dismiss }`. Throws when no Provider is in scope.
- `SnackbarToastProvider.vue` owns the toasts array, provides the API,
  and replaces the previous toast on rapid show() (singleton policy
  preserved). Renders `<ToastViewport>` at the same bottom-center
  position as before.
- `SnackbarToast.vue` is now a single `<ToastRoot>` item renderer,
  driven by a typed `toast` prop. Action handler is wrapped in
  try/finally so a throwing callback still dismisses and is logged.
- Stories wrap each variant in `<SnackbarToastProvider>` with simple
  trigger components.
- Visuals match Figma node 6826:77784 (verified via Figma MCP).

Tests:
- `useSnackbarToast.test.ts` covers no-provider throw and inject
  contract.
- `SnackbarToastProvider.test.ts` covers initial empty state, show
  rendering, singleton replace, shortcut badge vs action exclusivity,
  action click + dismiss, throwing action still dismisses, dismiss(id)
  targeting, and unique-id guarantee.

Refs FE-484
2026-04-29 09:44:55 +09:00
dante01yoon
a9695a7e1a refactor: move SnackbarToast under components/toast
Belongs with the existing toast components (`GlobalToast`,
`ProgressToastItem`, `RerouteMigrationToast`), not under
`components/graph/`. Story title updated to `Components/Toast/SnackbarToast`.
2026-04-29 08:39:38 +09:00
dante01yoon
719de7c828 feat: add SnackbarToast component for canvas feedback
Introduces a singleton snackbar toast component (bottom-center, Teleport
to body) intended for non-blocking canvas feedback. Surfaces an optional
keybinding badge or an action button (e.g. Undo) and supports auto-
dismiss with hover-pause.

Component-only — no app integration in this PR. Wiring to specific
canvas commands (link visibility, focus mode, subgraph unpack) will land
in follow-up PRs once the UX direction is settled (#11718 thread).

Visuals match Figma node 6826:77784 in the Comfy Design System.

Refs FE-484
2026-04-29 08:17:35 +09: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
148 changed files with 5001 additions and 874 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

@@ -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

@@ -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

@@ -140,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

@@ -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)
}
}
@@ -160,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
@@ -206,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)

View File

@@ -1,6 +1,12 @@
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
@@ -8,24 +14,91 @@ export class ComfyNodeSearchBoxV2 {
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.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
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,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

@@ -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

@@ -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

@@ -55,29 +55,32 @@ export class NodeOperationsHelper {
* 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 cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
* 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'>,
cursorPosition?: Position
position?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, cursor]) => {
([nodeType, opts, pos]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && cursor) {
if (opts?.ghost && pos) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: cursor.x,
clientY: cursor.y
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 ?? {}, cursorPosition ?? null] as const
[type, options ?? {}, position ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}

View File

@@ -86,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'
@@ -249,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

@@ -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

@@ -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

@@ -201,12 +201,10 @@ for (const mode of ['litegraph', 'vue'] as const) {
'subgraph blueprint added from search box enters ghost mode',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
await comfyPage.searchBoxV2.reload(comfyPage)
// Convert a node to a subgraph and publish it as a blueprint
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
@@ -231,9 +229,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
// Open v2 search box and search for the published blueprint
await comfyPage.canvasOps.doubleClick()
const { searchBoxV2 } = comfyPage
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill(blueprintName)
await expect(searchBoxV2.results.first()).toBeVisible()

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,16 +27,12 @@ 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)
@@ -63,12 +46,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
])
await searchBoxV2.reload(comfyPage)
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.filterBarButton('Bookmarked').click()
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('favorites').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -79,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)
})
})
@@ -93,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.filterSearch.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()
})
})
@@ -122,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.filterSearch.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'] }, () => {

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

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

@@ -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

@@ -4,12 +4,12 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
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/helpers/promotedWidgets'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
import {
getPromotedWidgetCount,
getPromotedWidgetNames,
getPromotedWidgets
} from '@e2e/helpers/promotedWidgets'
} from '@e2e/fixtures/utils/promotedWidgets'
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =

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

@@ -475,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.'
}
]
}
@@ -493,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

@@ -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;

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,360 @@
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'
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 = { id: 'node-1', type: 'Load3D' } as unknown as LGraphNode
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

@@ -37,7 +37,7 @@
</div>
</div>
<div v-if="!hideMaterialMode" class="show-material-mode relative">
<div v-if="materialModes.length > 0" class="show-material-mode relative">
<Button
v-tooltip.right="{
value: t('load3d.materialMode'),
@@ -93,7 +93,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -105,12 +105,10 @@ import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
const {
hideMaterialMode = false,
isPlyModel = false,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()
@@ -131,22 +129,6 @@ const upDirections: UpDirection[] = [
'+z'
]
const materialModes = computed(() => {
const modes: MaterialMode[] = [
'original',
'normal',
'wireframe'
//'depth' disable for now
]
// Only show pointCloud mode for PLY files (point cloud rendering)
if (isPlyModel) {
modes.splice(1, 0, 'pointCloud')
}
return modes
})
function toggleUpDirection() {
showUpDirection.value = !showUpDirection.value
showMaterialMode.value = false

View File

@@ -0,0 +1,194 @@
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 ViewerModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
upDirection: 'Up direction',
materialMode: 'Material mode',
upDirections: { original: 'Original' },
materialModes: {
original: 'Original',
normal: 'Normal',
wireframe: 'Wireframe',
pointCloud: 'Point Cloud',
depth: 'Depth'
}
}
}
}
})
type RenderProps = {
upDirection?: UpDirection
materialMode?: MaterialMode
materialModes?: readonly MaterialMode[]
'onUpdate:upDirection'?: (value: UpDirection | undefined) => void
'onUpdate:materialMode'?: (value: MaterialMode | undefined) => void
}
function renderControls(overrides: RenderProps = {}) {
const result = render(ViewerModelControls, {
props: {
upDirection: 'original',
materialMode: 'original',
materialModes: ['original', 'normal', 'wireframe'],
...overrides
},
global: {
plugins: [i18n]
}
})
return { ...result, user: userEvent.setup() }
}
function getOptions(select: HTMLElement) {
return Array.from(select.querySelectorAll('option'))
}
describe('ViewerModelControls', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('renders both up direction and material mode selects by default', () => {
renderControls()
expect(screen.getAllByRole('combobox')).toHaveLength(2)
expect(screen.getByText('Up direction')).toBeInTheDocument()
expect(screen.getByText('Material mode')).toBeInTheDocument()
})
it('hides the material mode select when materialModes is empty', () => {
renderControls({ materialModes: [] })
expect(screen.getAllByRole('combobox')).toHaveLength(1)
expect(screen.queryByText('Material mode')).not.toBeInTheDocument()
})
})
describe('up direction options', () => {
it('exposes the seven supported directions', () => {
renderControls()
const [upDirectionSelect] = screen.getAllByRole('combobox')
const options = getOptions(upDirectionSelect)
expect(options.map((o) => o.getAttribute('value'))).toEqual([
'original',
'-x',
'+x',
'-y',
'+y',
'-z',
'+z'
])
})
it('localizes the "original" option label and uses raw axis labels for the rest', () => {
renderControls()
const [upDirectionSelect] = screen.getAllByRole('combobox')
const options = getOptions(upDirectionSelect)
expect(options.map((o) => o.textContent?.trim())).toEqual([
'Original',
'-X',
'+X',
'-Y',
'+Y',
'-Z',
'+Z'
])
})
})
describe('material mode options', () => {
it('emits one option per materialModes entry with localized labels', () => {
renderControls({ materialModes: ['original', 'normal', 'wireframe'] })
const [, materialModeSelect] = screen.getAllByRole('combobox')
const options = getOptions(materialModeSelect)
expect(options.map((o) => o.getAttribute('value'))).toEqual([
'original',
'normal',
'wireframe'
])
expect(options.map((o) => o.textContent?.trim())).toEqual([
'Original',
'Normal',
'Wireframe'
])
})
it('includes pointCloud when the adapter exposes it (PLY)', () => {
renderControls({
materialModes: ['original', 'pointCloud', 'normal', 'wireframe']
})
const [, materialModeSelect] = screen.getAllByRole('combobox')
const options = getOptions(materialModeSelect)
expect(options).toHaveLength(4)
expect(options[1].textContent?.trim()).toBe('Point Cloud')
expect(options[1].getAttribute('value')).toBe('pointCloud')
})
})
describe('v-model binding', () => {
it('renders the initial upDirection as the selected option', () => {
renderControls({ upDirection: '-z' })
const [upDirectionSelect] = screen.getAllByRole('combobox')
expect((upDirectionSelect as HTMLSelectElement).value).toBe('-z')
})
it('renders the initial materialMode as the selected option', () => {
renderControls({ materialMode: 'normal' })
const [, materialModeSelect] = screen.getAllByRole('combobox')
expect((materialModeSelect as HTMLSelectElement).value).toBe('normal')
})
it('emits update:upDirection when a new direction is chosen', async () => {
const listener = vi.fn()
const { user } = renderControls({ 'onUpdate:upDirection': listener })
const [upDirectionSelect] = screen.getAllByRole('combobox')
await user.selectOptions(upDirectionSelect, '+x')
expect(listener).toHaveBeenCalledWith('+x')
})
it('emits update:materialMode when a new mode is chosen', async () => {
const listener = vi.fn()
const { user } = renderControls({ 'onUpdate:materialMode': listener })
const [, materialModeSelect] = screen.getAllByRole('combobox')
await user.selectOptions(materialModeSelect, 'wireframe')
expect(listener).toHaveBeenCalledWith('wireframe')
})
})
})

View File

@@ -10,7 +10,7 @@
/>
</div>
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
<label>{{ $t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
@@ -33,9 +33,8 @@ import type {
} from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean
const { materialModes = ['original', 'normal', 'wireframe'] } = defineProps<{
materialModes?: readonly MaterialMode[]
}>()
const upDirection = defineModel<UpDirection>('upDirection')
@@ -51,23 +50,10 @@ const upDirectionOptions = [
{ label: '+Z', value: '+z' }
]
const materialModeOptions = computed(() => {
const options = [
{ label: t('load3d.materialModes.original'), value: 'original' }
]
if (isPlyModel) {
options.push({
label: t('load3d.materialModes.pointCloud'),
value: 'pointCloud'
})
}
options.push(
{ label: t('load3d.materialModes.normal'), value: 'normal' },
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
)
return options
})
const materialModeOptions = computed(() =>
materialModes.map((mode) => ({
label: t(`load3d.materialModes.${mode}`),
value: mode
}))
)
</script>

View File

@@ -8,17 +8,15 @@ import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionPolicy'
import {
getPromotableWidgets,
isRecommendedWidget
} from '@/core/graph/subgraph/promotionPolicy'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

View File

@@ -1,59 +1,34 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s]))
const { addNodeOnGraph } = vi.hoisted(() => ({
addNodeOnGraph: vi.fn()
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
getCanvasCenter: vi.fn(() => [0, 0]),
addNodeOnGraph: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: null,
getCanvas: vi.fn(() => ({
linkConnector: {
events: new EventTarget(),
renderLinks: []
}
}))
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeSearchService: {
nodeFilters: [],
inputTypeFilter: {},
outputTypeFilter: {}
}
addNodeOnGraph
})
}))
type EmitAddFilter = (
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => void
type EmitAddNode = (nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) => void
function createFilter(
id: string,
@@ -72,26 +47,48 @@ describe('NodeSearchBoxPopover', () => {
messages: { en: {} }
})
function renderComponent() {
function renderComponent(settings: Partial<Settings> = {}) {
let emitAddFilter: EmitAddFilter | null = null
let emitAddNodeV1: EmitAddNode | null = null
let emitAddNodeV2: EmitAddNode | null = null
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
emits: ['addFilter'],
emits: ['addFilter', 'addNode'],
setup(props, { emit }) {
emitAddFilter = (filter) => emit('addFilter', filter)
emitAddNodeV1 = (nodeDef, dragEvent) =>
emit('addNode', nodeDef, dragEvent)
const filterCount = computed(() => props.filters.length)
return { filterCount }
},
template: '<output aria-label="filter count">{{ filterCount }}</output>'
})
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
emitAddNodeV2 = (nodeDef, dragEvent) =>
emit('addNode', nodeDef, dragEvent)
return {}
},
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
stubActions: false,
initialState: {
setting: {
settingValues: settings,
settingsById: coreSettingsById
},
searchBox: { visible: false }
}
})
@@ -101,6 +98,8 @@ describe('NodeSearchBoxPopover', () => {
plugins: [i18n, PrimeVue, pinia],
stubs: {
NodeSearchBox: NodeSearchBoxStub,
NodeSearchContent: NodeSearchContentStub,
NodePreviewCard: true,
Dialog: {
template: '<div><slot name="container" /></div>',
props: ['visible', 'modal', 'dismissableMask', 'pt']
@@ -109,14 +108,34 @@ describe('NodeSearchBoxPopover', () => {
}
})
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
return { ...result, emitAddFilter: emitAddFilter as EmitAddFilter }
return {
...result,
get emitAddFilter() {
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
return emitAddFilter
},
get emitAddNodeV1() {
if (!emitAddNodeV1) throw new Error('NodeSearchBox stub did not mount')
return emitAddNodeV1
},
get emitAddNodeV2() {
if (!emitAddNodeV2)
throw new Error('NodeSearchContent stub did not mount')
return emitAddNodeV2
}
}
}
beforeEach(() => {
addNodeOnGraph.mockReset()
addNodeOnGraph.mockReturnValue(null)
})
describe('addFilter duplicate prevention', () => {
it('should add a filter when no duplicates exist', async () => {
const { emitAddFilter } = renderComponent()
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
@@ -125,7 +144,9 @@ describe('NodeSearchBoxPopover', () => {
})
it('should not add a duplicate filter with same id and value', async () => {
const { emitAddFilter } = renderComponent()
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
@@ -136,7 +157,9 @@ describe('NodeSearchBoxPopover', () => {
})
it('should allow filters with same id but different values', async () => {
const { emitAddFilter } = renderComponent()
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
@@ -147,7 +170,9 @@ describe('NodeSearchBoxPopover', () => {
})
it('should allow filters with different ids but same value', async () => {
const { emitAddFilter } = renderComponent()
const { emitAddFilter } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
})
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
@@ -157,4 +182,98 @@ describe('NodeSearchBoxPopover', () => {
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
})
})
describe('addNode ghost flag (FollowCursor setting)', () => {
const nodeDef = { name: 'KSampler' } as ComfyNodeDefImpl
it('should default ghost to true when v2 search is active and FollowCursor is unset', async () => {
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default'
})
emitAddNodeV2(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: true })
)
})
it('should pass ghost: true when v2 search is active and FollowCursor is enabled', async () => {
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV2(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: true })
)
})
it('should pass ghost: false when v2 search is active but FollowCursor is disabled', async () => {
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default',
'Comfy.NodeSearchBoxImpl.FollowCursor': false
})
emitAddNodeV2(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: false })
)
})
it('should pass ghost: false when v1 legacy search box is used', async () => {
const { emitAddNodeV1 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV1(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: false })
)
})
it('should pass ghost: false when litegraph legacy search box is used', async () => {
const { emitAddNodeV1 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'litegraph (legacy)',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV1(nodeDef)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: false })
)
})
it('should forward the dragEvent through to addNodeOnGraph', async () => {
const dragEvent = new MouseEvent('mousedown')
const { emitAddNodeV2 } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default',
'Comfy.NodeSearchBoxImpl.FollowCursor': true
})
emitAddNodeV2(nodeDef, dragEvent)
await nextTick()
expect(addNodeOnGraph).toHaveBeenCalledWith(
nodeDef,
expect.objectContaining({ pos: expect.any(Array) }),
expect.objectContaining({ ghost: true, dragEvent })
)
})
})
})

View File

@@ -129,10 +129,11 @@ function closeDialog() {
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value, dragEvent }
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
if (!node) return

View File

@@ -84,6 +84,7 @@
</div>
<div
v-if="displayedResults.length === 0"
data-testid="no-results"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}

View File

@@ -5,6 +5,7 @@
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
:data-testid="`search-category-${btn.id}`"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
@@ -24,7 +25,11 @@
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<button type="button" :class="chipClass(false, tf.values.length > 0)">
<button
type="button"
:data-testid="`search-filter-${tf.chip.key}`"
:class="chipClass(false, tf.values.length > 0)"
>
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
@@ -57,8 +62,8 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -96,20 +101,20 @@ const MAX_VISIBLE_DOTS = 4
const categoryButtons = computed(() => {
const buttons: { id: string; label: string }[] = []
if (hasFavorites) {
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') })
}
if (hasBlueprintNodes) {
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') })
}
if (hasPartnerNodes) {
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
}
if (hasEssentialNodes) {
buttons.push({ id: 'essentials', label: t('g.essentials') })
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
}
buttons.push({ id: 'comfy', label: t('g.comfy') })
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
if (hasCustomNodes) {
buttons.push({ id: 'custom', label: t('g.extensions') })
buttons.push({ id: RootCategory.Custom, label: t('g.extensions') })
}
return buttons
})

View File

@@ -18,6 +18,7 @@
/>
<span
v-if="showIdName"
data-testid="node-id-badge"
class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
v-html="highlightQuery(nodeDef.name, currentQuery)"
/>

View File

@@ -0,0 +1,12 @@
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
export const RootCategory = {
Favorites: 'favorites',
Comfy: 'comfy',
Custom: 'custom',
Essentials: 'essentials',
PartnerNodes: 'partner-nodes',
Blueprint: BLUEPRINT_CATEGORY
} as const
export type RootCategoryId = (typeof RootCategory)[keyof typeof RootCategory]

View File

@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import { useSnackbarToast } from '@/composables/useSnackbarToast'
import SnackbarToastProvider from './SnackbarToastProvider.vue'
const meta: Meta<typeof SnackbarToastProvider> = {
title: 'Components/Toast/SnackbarToast',
component: SnackbarToastProvider,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen'
},
decorators: [
() => ({
template:
'<div class="relative h-screen bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { SnackbarToastProvider, Button, Trigger },
template: `
<SnackbarToastProvider>
<Trigger label="Show toast" message="Toast message" />
</SnackbarToastProvider>
`
})
}
export const WithShortcut: Story = {
render: () => ({
components: { SnackbarToastProvider, Button, TriggerWithShortcut },
template: `
<SnackbarToastProvider>
<TriggerWithShortcut />
</SnackbarToastProvider>
`
})
}
export const WithUndoAction: Story = {
render: () => ({
components: { SnackbarToastProvider, Button, TriggerWithUndo },
template: `
<SnackbarToastProvider>
<TriggerWithUndo />
</SnackbarToastProvider>
`
})
}
export const Persistent: Story = {
render: () => ({
components: { SnackbarToastProvider, Button, TriggerPersistent },
template: `
<SnackbarToastProvider>
<TriggerPersistent />
</SnackbarToastProvider>
`
})
}
const Trigger = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return { trigger: () => toast.show('Toast message') }
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}
const TriggerWithShortcut = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return {
trigger: () => toast.show('Links hidden', { shortcut: 'Ctrl+A' })
}
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}
const TriggerWithUndo = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return {
trigger: () =>
toast.show('Subgraph unpacked', {
actionLabel: 'Undo',
onAction: () => toast.show('Subgraph repacked')
})
}
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}
const TriggerPersistent = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return {
trigger: () =>
toast.show('Stays open until dismissed', { duration: 60_000 })
}
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}

View File

@@ -0,0 +1,77 @@
<template>
<ToastRoot
:duration="toast.duration ?? DEFAULT_DURATION"
type="foreground"
class="flex items-center gap-4 rounded-lg bg-base-foreground py-1 pr-2 pl-3 text-sm text-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)] outline-none data-[state=closed]:opacity-0 data-[state=closed]:transition-opacity data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform data-[swipe=end]:translate-y-(--reka-toast-swipe-end-y) data-[swipe=end]:transition-transform data-[swipe=move]:translate-y-(--reka-toast-swipe-move-y)"
@update:open="handleOpenChange"
>
<ToastTitle class="truncate">
{{ toast.message }}
</ToastTitle>
<kbd
v-if="toast.shortcut"
class="flex h-4 min-w-3.5 items-center justify-center rounded-sm bg-base-background/70 px-1 text-xs font-normal text-base-foreground"
>
{{ toast.shortcut }}
</kbd>
<div class="flex items-center pl-2">
<ToastAction
v-if="hasAction"
as-child
:alt-text="toast.actionLabel ?? ''"
@click.prevent="handleAction"
>
<Button
variant="inverted"
size="md"
class="text-sm hover:bg-base-foreground/80"
>
{{ toast.actionLabel }}
</Button>
</ToastAction>
<ToastClose as-child :aria-label="t('g.dismiss')">
<Button
variant="inverted"
size="md"
class="hover:bg-base-foreground/80"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</ToastClose>
</div>
</ToastRoot>
</template>
<script setup lang="ts">
import { ToastAction, ToastClose, ToastRoot, ToastTitle } from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SnackbarToastItem } from '@/composables/useSnackbarToast'
const DEFAULT_DURATION = 2000
const { toast } = defineProps<{ toast: SnackbarToastItem }>()
const emit = defineEmits<{
dismiss: []
}>()
const { t } = useI18n()
const hasAction = computed(() => !!toast.onAction && !toast.shortcut)
function handleOpenChange(open: boolean) {
if (!open) emit('dismiss')
}
function handleAction() {
try {
toast.onAction?.()
} catch (err) {
console.error('SnackbarToast action handler threw:', err)
} finally {
emit('dismiss')
}
}
</script>

View File

@@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SnackbarToastApi } from '@/composables/useSnackbarToast'
import { useSnackbarToast } from '@/composables/useSnackbarToast'
import SnackbarToastProvider from './SnackbarToastProvider.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { dismiss: 'Dismiss' } } }
})
let capturedApi: SnackbarToastApi | null = null
const Harness = defineComponent({
setup() {
capturedApi = useSnackbarToast()
return () => h('div', { 'data-testid': 'harness' })
}
})
function setup(): {
user: ReturnType<typeof userEvent.setup>
api: SnackbarToastApi
unmount: () => void
} {
capturedApi = null
const user = userEvent.setup()
const { unmount } = render(SnackbarToastProvider, {
slots: { default: () => h(Harness) },
global: { plugins: [i18n] }
})
const api = capturedApi
if (!api) throw new Error('Harness did not capture api')
return { user, api, unmount }
}
describe('SnackbarToastProvider', () => {
beforeEach(() => {
document.body.innerHTML = ''
// happy-dom doesn't implement these; reka-ui ToastClose/ToastAction call them
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false
Element.prototype.releasePointerCapture = () => {}
Element.prototype.setPointerCapture = () => {}
}
})
afterEach(() => {
capturedApi = null
})
it('renders no toast initially', () => {
setup()
expect(screen.getByTestId('harness')).toBeInTheDocument()
expect(screen.queryAllByRole('status')).toHaveLength(0)
})
it('renders a toast after show()', async () => {
const { api } = setup()
api.show('Hello world')
await nextTick()
expect(screen.getByText('Hello world')).toBeInTheDocument()
})
it('replaces an existing toast on rapid show() (singleton)', async () => {
const { api } = setup()
api.show('first')
api.show('second')
await nextTick()
expect(screen.queryByText('first')).not.toBeInTheDocument()
expect(screen.getByText('second')).toBeInTheDocument()
})
it('renders a shortcut badge when shortcut is provided', async () => {
const { api } = setup()
api.show('Links hidden', { shortcut: 'Ctrl+A' })
await nextTick()
const badge = screen.getByText('Ctrl+A')
expect(badge).toBeInTheDocument()
// when shortcut is set, the action button must NOT render
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull()
})
it('renders an action button when actionLabel is provided without shortcut', async () => {
const { api } = setup()
const onAction = vi.fn()
api.show('Subgraph unpacked', { actionLabel: 'Undo', onAction })
await nextTick()
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument()
})
it('does not render action button when shortcut is also set', async () => {
const { api } = setup()
api.show('msg', {
shortcut: 'Ctrl+A',
actionLabel: 'Undo',
onAction: vi.fn()
})
await nextTick()
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull()
})
it('action click invokes the callback and dismisses the toast', async () => {
const { user, api } = setup()
const onAction = vi.fn()
api.show('msg', { actionLabel: 'Undo', onAction })
await nextTick()
await user.click(screen.getByRole('button', { name: 'Undo' }))
await nextTick()
expect(onAction).toHaveBeenCalledTimes(1)
expect(screen.queryByText('msg')).not.toBeInTheDocument()
})
it('dismisses the toast even when the action callback throws', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { user, api } = setup()
const onAction = vi.fn(() => {
throw new Error('boom')
})
api.show('msg', { actionLabel: 'Undo', onAction })
await nextTick()
await user.click(screen.getByRole('button', { name: 'Undo' }))
await nextTick()
expect(onAction).toHaveBeenCalledTimes(1)
expect(screen.queryByText('msg')).not.toBeInTheDocument()
expect(errSpy).toHaveBeenCalled()
errSpy.mockRestore()
})
it('dismiss(id) removes the targeted toast', async () => {
const { api } = setup()
const id = api.show('first')
await nextTick()
expect(screen.getByText('first')).toBeInTheDocument()
api.dismiss(id)
await nextTick()
expect(screen.queryByText('first')).not.toBeInTheDocument()
})
it('dismiss(id) for an unknown id is a no-op', async () => {
const { api } = setup()
api.show('first')
await nextTick()
api.dismiss('non-existent')
await nextTick()
expect(screen.getByText('first')).toBeInTheDocument()
})
it('show() returns a unique id per call', () => {
const { api } = setup()
const a = api.show('a')
const b = api.show('b')
expect(a).not.toEqual(b)
})
})

View File

@@ -0,0 +1,50 @@
<template>
<ToastProvider swipe-direction="down" :duration="DEFAULT_DURATION">
<slot />
<SnackbarToast
v-for="item in toasts"
:key="item.id"
:toast="item"
@dismiss="dismiss(item.id)"
/>
<ToastViewport
class="fixed bottom-16 left-1/2 z-1000 m-0 flex -translate-x-1/2 list-none flex-col items-center gap-2 p-0 outline-none"
/>
</ToastProvider>
</template>
<script setup lang="ts">
import { ToastProvider, ToastViewport } from 'reka-ui'
import { provide, ref } from 'vue'
import type {
ShowSnackbarOptions,
SnackbarToastApi,
SnackbarToastItem
} from '@/composables/useSnackbarToast'
import { SnackbarToastKey } from '@/composables/useSnackbarToast'
import SnackbarToast from './SnackbarToast.vue'
const DEFAULT_DURATION = 2000
const toasts = ref<SnackbarToastItem[]>([])
function createId(): string {
return `snackbar-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function show(message: string, options: ShowSnackbarOptions = {}): string {
const item: SnackbarToastItem = { id: createId(), message, ...options }
toasts.value = [item]
return item.id
}
function dismiss(id: string): void {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
const api: SnackbarToastApi = { show, dismiss }
provide(SnackbarToastKey, api)
defineExpose(api)
</script>

View File

@@ -1,5 +1,8 @@
<template>
<div class="flex h-full shrink-0 items-center gap-1 empty:hidden">
<div
data-testid="action-bar-buttons"
class="flex h-full shrink-0 items-center gap-1 empty:hidden"
>
<Button
v-for="(button, index) in actionBarButtonStore.buttons"
:key="index"

View File

@@ -1,6 +1,7 @@
<template>
<Button
v-if="!isLoggedIn"
data-testid="login-button"
variant="textonly"
size="icon"
:class="cn('group rounded-full p-0 text-base-foreground', className)"
@@ -21,9 +22,10 @@
@mouseout="hidePopover"
@mouseover="cancelHidePopover"
>
<div>
<div data-testid="login-button-popover">
<div class="mb-1">{{ t('auth.loginButton.tooltipHelp') }}</div>
<a
data-testid="login-button-popover-learn-more"
:href="apiNodesOverviewUrl"
target="_blank"
class="text-neutral-500 hover:text-primary"

View File

@@ -1,14 +1,12 @@
/// <reference types="@webgpu/types" />
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { debounce } from 'es-toolkit/compat'
import { parseToRgb } from '@/utils/colorUtil'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import {
Tools,
BrushShape,
CompositionOperation
} from '@/extensions/core/maskeditor/types'
import type { Brush, Point } from '@/extensions/core/maskeditor/types'
import type { Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useCoordinateTransform } from './useCoordinateTransform'
import { resampleSegment } from './splineUtils'
@@ -24,45 +22,14 @@ import {
drawMaskShape
} from './brushDrawingUtils'
import type { DirtyRect } from './brushDrawingUtils'
/**
* Saves the brush settings to local storage with a debounce.
* @param key - The storage key.
* @param brush - The brush settings object.
*/
const saveBrushToCache = debounce(function (key: string, brush: Brush): void {
try {
const brushString = JSON.stringify(brush)
setStorageValue(key, brushString)
} catch (error) {
console.error('Failed to save brush to cache:', error)
}
}, 300)
/**
* Loads brush settings from local storage.
* @param key - The storage key.
* @returns The brush settings object or null if not found.
*/
function loadBrushFromCache(key: string): Brush | null {
try {
const brushString = getStorageValue(key)
if (brushString) {
return JSON.parse(brushString) as Brush
} else {
return null
}
} catch (error) {
console.error('Failed to load brush from cache:', error)
return null
}
}
import { useBrushPersistence } from './useBrushPersistence'
export function useBrushDrawing(initialSettings?: {
useDominantAxis?: boolean
brushAdjustmentSpeed?: number
}) {
const store = useMaskEditorStore()
const persistence = useBrushPersistence()
const coordinateTransform = useCoordinateTransform()
@@ -100,14 +67,7 @@ export function useBrushDrawing(initialSettings?: {
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
if (cachedBrushSettings) {
store.setBrushSize(cachedBrushSettings.size)
store.setBrushOpacity(cachedBrushSettings.opacity)
store.setBrushHardness(cachedBrushSettings.hardness)
store.brushSettings.type = cachedBrushSettings.type
store.setBrushStepSize(cachedBrushSettings.stepSize ?? 5)
}
persistence.loadAndApply()
// Handle external clear events
watch(
@@ -865,13 +825,6 @@ export function useBrushDrawing(initialSettings?: {
store.setBrushHardness(newHardness)
}
/**
* Saves the current brush settings to cache.
*/
function saveBrushSettings(): void {
saveBrushToCache('maskeditor_brush_settings', store.brushSettings)
}
/**
* Reads back the GPU textures to CPU ImageDatas.
* @returns Object containing mask and rgb ImageDatas.
@@ -1272,7 +1225,7 @@ export function useBrushDrawing(initialSettings?: {
drawEnd,
startBrushAdjustment,
handleBrushAdjustment,
saveBrushSettings,
saveBrushSettings: persistence.save,
destroy,
initGPUResources,
initPreviewCanvas,

View File

@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
vi.mock('es-toolkit/compat', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
debounce: vi.fn((fn: (...args: unknown[]) => void) => {
const immediate = (...args: unknown[]) => fn(...args)
immediate.cancel = vi.fn()
return immediate
})
}
})
vi.mock('@/scripts/utils', () => ({
getStorageValue: vi.fn((key: string) => localStorage.getItem(key)),
setStorageValue: vi.fn((key: string, value: string) => {
localStorage.setItem(key, value)
})
}))
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useBrushPersistence } from './useBrushPersistence'
const STORAGE_KEY = 'maskeditor_brush_settings'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
vi.resetAllMocks()
})
describe('loadAndApply', () => {
it('does not mutate the store when localStorage is empty', () => {
const store = useMaskEditorStore()
const sizeBefore = store.brushSettings.size
const { loadAndApply } = useBrushPersistence()
loadAndApply()
expect(store.brushSettings.size).toBe(sizeBefore)
})
it('restores all brush properties from a previous save', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
size: 42,
opacity: 0.7,
hardness: 0.3,
type: 'arc',
stepSize: 10
})
)
const store = useMaskEditorStore()
const { loadAndApply } = useBrushPersistence()
loadAndApply()
expect(store.brushSettings.size).toBe(42)
expect(store.brushSettings.opacity).toBe(0.7)
expect(store.brushSettings.hardness).toBe(0.3)
expect(store.brushSettings.stepSize).toBe(10)
})
it('falls back to stepSize=5 when the field is missing from stored data', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ size: 20, opacity: 0.8, hardness: 0.5, type: 'arc' })
)
const store = useMaskEditorStore()
const { loadAndApply } = useBrushPersistence()
loadAndApply()
expect(store.brushSettings.stepSize).toBe(5)
})
it('does not throw on corrupted localStorage data', () => {
localStorage.setItem(STORAGE_KEY, 'not-valid-json')
const { loadAndApply } = useBrushPersistence()
expect(() => loadAndApply()).not.toThrow()
})
})
describe('save', () => {
it('writes current brush settings to localStorage', () => {
const store = useMaskEditorStore()
store.brushSettings.size = 99
const { save } = useBrushPersistence()
save()
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
expect(saved.size).toBe(99)
})
it('captures settings at call time so a subsequent store reset does not overwrite the save', () => {
const store = useMaskEditorStore()
store.brushSettings.size = 77
const { save } = useBrushPersistence()
save()
store.brushSettings.size = 10
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
expect(saved.size).toBe(77)
})
})

View File

@@ -0,0 +1,48 @@
import { debounce } from 'es-toolkit/compat'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import type { Brush } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const STORAGE_KEY = 'maskeditor_brush_settings'
function loadBrushFromStorage(): Brush | null {
try {
const brushString = getStorageValue(STORAGE_KEY)
if (brushString) {
return JSON.parse(brushString) as Brush
}
return null
} catch (error) {
console.error('Failed to load brush from cache:', error)
return null
}
}
const debouncedWrite = debounce((serialized: string): void => {
try {
setStorageValue(STORAGE_KEY, serialized)
} catch (error) {
console.error('Failed to save brush to cache:', error)
}
}, 300)
export function useBrushPersistence() {
const store = useMaskEditorStore()
function save(): void {
debouncedWrite(JSON.stringify(store.brushSettings))
}
function loadAndApply(): void {
const cached = loadBrushFromStorage()
if (!cached) return
store.setBrushSize(cached.size)
store.setBrushOpacity(cached.opacity)
store.setBrushHardness(cached.hardness)
store.brushSettings.type = cached.type
store.setBrushStepSize(cached.stepSize ?? 5)
}
return { loadAndApply, save }
}

View File

@@ -10,6 +10,7 @@ import {
useNodePricing
} from '@/composables/node/useNodePricing'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
@@ -577,6 +578,81 @@ describe('useNodePricing', () => {
const config = getNodePricingConfig(node)
expect(config).toBeUndefined()
})
it('does not leak the compiled JSONata expression', () => {
const { getNodePricingConfig } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestStripCompiledNode',
priceBadge('{"type":"usd","usd":0.05}')
)
const config = getNodePricingConfig(node)
expect(config).toBeDefined()
// _compiled is the runtime JSONata instance and must not be exposed to
// tooling/debug consumers.
expect(config).not.toHaveProperty('_compiled')
})
})
describe('reactive revision', () => {
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestRevisionNode',
priceBadge('{"type":"usd","usd":0.05}')
)
const before = pricingRevision.value
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
expect(pricingRevision.value).toBeGreaterThan(before)
})
it('bumps the per-node revision ref after async evaluation resolves in VueNodes mode', async () => {
const { getNodeDisplayPrice, getNodeRevisionRef, pricingRevision } =
useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestVueNodeRevision',
priceBadge('{"type":"usd","usd":0.05}')
)
LiteGraph.vueNodesMode = true
try {
const revBefore = getNodeRevisionRef(node.id).value
const tickBefore = pricingRevision.value
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
// VueNodes path bumps per-node ref instead of the global tick.
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
expect(pricingRevision.value).toBe(tickBefore)
} finally {
LiteGraph.vueNodesMode = false
}
})
it('returns the cached label on a second call with the same signature', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestCachedSignatureNode',
priceBadge('{"type":"usd","usd":0.05}')
)
// First call schedules eval; second call (after resolution) is a cache hit.
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
const first = getNodeDisplayPrice(node)
const tickAfterFirst = pricingRevision.value
const second = getNodeDisplayPrice(node)
// Cache-hit path must not schedule a new evaluation, so no further tick.
await new Promise((r) => setTimeout(r, 20))
expect(second).toBe(first)
expect(pricingRevision.value).toBe(tickAfterFirst)
})
})
describe('getNodeRevisionRef', () => {
@@ -977,6 +1053,47 @@ describe('formatPricingResult', () => {
expect(result).toBe('')
})
})
describe('non-finite numbers', () => {
it('returns empty for type:usd when usd is a non-numeric string', () => {
const result = formatPricingResult({ type: 'usd', usd: 'not-a-number' })
expect(result).toBe('')
})
it('returns empty for type:usd when usd is Infinity', () => {
const result = formatPricingResult({ type: 'usd', usd: Infinity })
expect(result).toBe('')
})
it('returns empty for type:range_usd when min_usd or max_usd is NaN', () => {
expect(
formatPricingResult({ type: 'range_usd', min_usd: NaN, max_usd: 0.1 })
).toBe('')
expect(
formatPricingResult({ type: 'range_usd', min_usd: 0.05, max_usd: NaN })
).toBe('')
})
it('returns empty for type:list_usd when usd is empty or all values are non-finite', () => {
expect(formatPricingResult({ type: 'list_usd', usd: [] })).toBe('')
expect(
formatPricingResult({ type: 'list_usd', usd: [NaN, 'x', null] })
).toBe('')
})
it('drops non-finite entries from type:list_usd while keeping finite ones', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, NaN, 0.1] },
{ valueOnly: true }
)
expect(result).toBe('10.6/21.1')
})
it('returns empty for legacy {usd} format when usd is non-finite', () => {
expect(formatPricingResult({ usd: NaN })).toBe('')
expect(formatPricingResult({ usd: 'abc' })).toBe('')
})
})
})
// -----------------------------------------------------------------------------

View File

@@ -141,6 +141,15 @@ describe('useLoad3d', () => {
exportModel: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}),
hasSkeleton: vi.fn().mockReturnValue(false),
setShowSkeleton: vi.fn(),
loadHDRI: vi.fn().mockResolvedValue(undefined),

View File

@@ -97,6 +97,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isPreview = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const canFitToViewer = ref(true)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
const canExport = ref(true)
const materialModes = ref<readonly MaterialMode[]>([
'original',
'normal',
'wireframe'
])
const initializeLoad3d = async (containerRef: HTMLElement) => {
const rawNode = toRaw(nodeRef.value)
@@ -785,6 +794,16 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
canUseGizmo.value = caps?.gizmoTransform ?? true
canUseLighting.value = caps?.lighting ?? true
canExport.value = caps?.exportable ?? true
materialModes.value = caps?.materialModes ?? [
'original',
'normal',
'wireframe'
]
hasSkeleton.value = load3d?.hasSkeleton() ?? false
applyGizmoConfigToLoad3d()
isFirstModelLoad = false
@@ -925,6 +944,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview,
isSplatModel,
isPlyModel,
canFitToViewer,
canUseGizmo,
canUseLighting,
canExport,
materialModes,
hasSkeleton,
hasRecording,
recordingDuration,

View File

@@ -21,7 +21,21 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
uploadFile: vi.fn()
uploadFile: vi.fn(),
splitFilePath: vi.fn((path: string) => {
const parts = path.split('/')
return [parts.slice(0, -1).join('/'), parts[parts.length - 1] ?? '']
}),
getResourceURL: vi.fn(
(subfolder: string, filename: string, type: string) =>
`api/view?type=${type}&subfolder=${encodeURIComponent(subfolder)}&filename=${filename}`
)
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((url: string) => `/${url}`)
}
}))
@@ -116,6 +130,15 @@ describe('useLoad3dViewer', () => {
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}),
setGizmoEnabled: vi.fn(),
setGizmoMode: vi.fn(),
setBackgroundRenderMode: vi.fn(),
@@ -536,6 +559,78 @@ describe('useLoad3dViewer', () => {
})
})
describe('handleModelDrop', () => {
it('refreshes the capability refs after the dropped model loads, so the sidebar reflects the new model', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'3d/dropped.splat'
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(viewer.canUseLighting.value).toBe(true)
expect(viewer.canUseGizmo.value).toBe(true)
expect(viewer.canExport.value).toBe(true)
expect([...viewer.materialModes.value]).toEqual([
'original',
'normal',
'wireframe'
])
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValueOnce(true)
vi.mocked(mockLoad3d.getCurrentModelCapabilities!).mockReturnValueOnce({
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 20
})
const file = new File([''], 'dropped.splat')
await viewer.handleModelDrop(file)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
expect.stringContaining('dropped.splat')
)
expect(viewer.canUseLighting.value).toBe(false)
expect(viewer.canExport.value).toBe(false)
expect(viewer.isSplatModel.value).toBe(true)
expect([...viewer.materialModes.value]).toEqual([])
})
it('alerts and does not call loadModel when there is no active load3d instance', async () => {
const viewer = useLoad3dViewer(mockNode)
const file = new File([''], 'whatever.glb')
await viewer.handleModelDrop(file)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dScene'
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
})
it('alerts and skips loadModel when the file upload fails', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('')
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
vi.mocked(mockLoad3d.loadModel!).mockClear()
const file = new File([''], 'whatever.glb')
await viewer.handleModelDrop(file)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.fileUploadFailed'
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
})
})
describe('cleanup', () => {
it('should clean up resources', async () => {
const viewer = useLoad3dViewer(mockNode)

View File

@@ -82,6 +82,26 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const isStandaloneMode = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const canFitToViewer = ref(true)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
const canExport = ref(true)
const materialModes = ref<readonly MaterialMode[]>([
'original',
'normal',
'wireframe'
])
const captureAdapterFlags = (source: Load3d) => {
isSplatModel.value = source.isSplatModel()
isPlyModel.value = source.isPlyModel()
const caps = source.getCurrentModelCapabilities()
canFitToViewer.value = caps.fitToViewer
canUseGizmo.value = caps.gizmoTransform
canUseLighting.value = caps.lighting
canExport.value = caps.exportable
materialModes.value = caps.materialModes
}
// Animation state
const animations = ref<AnimationItem[]>([])
@@ -395,8 +415,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
isSplatModel.value = source.isSplatModel()
isPlyModel.value = source.isPlyModel()
captureAdapterFlags(source)
initialState.value = {
backgroundColor: backgroundColor.value,
@@ -456,8 +475,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
captureAdapterFlags(load3d)
isPreview.value = true
@@ -480,8 +498,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
captureAdapterFlags(load3d)
} catch (error) {
console.error('Error loading model in standalone viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
@@ -765,6 +782,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
await load3d.loadModel(modelUrl)
captureAdapterFlags(load3d)
const modelWidget = node?.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
const options = modelWidget.options as { values?: string[] } | undefined
@@ -812,6 +831,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isStandaloneMode,
isSplatModel,
isPlyModel,
canFitToViewer,
canUseGizmo,
canUseLighting,
canExport,
materialModes,
// Animation state
animations,

View File

@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, provide } from 'vue'
import type { SnackbarToastApi } from './useSnackbarToast'
import { SnackbarToastKey, useSnackbarToast } from './useSnackbarToast'
const Consumer = defineComponent({
setup() {
const api = useSnackbarToast()
return () =>
h('div', { 'data-testid': 'consumer' }, [
h('span', { 'data-testid': 'has-show' }, String(typeof api.show)),
h('span', { 'data-testid': 'has-dismiss' }, String(typeof api.dismiss))
])
}
})
describe('useSnackbarToast', () => {
it('throws when no SnackbarToastProvider is in scope', () => {
expect(() => render(Consumer)).toThrow(/SnackbarToastProvider/)
})
it('returns the injected api', () => {
const api: SnackbarToastApi = {
show: vi.fn(() => 'id-1'),
dismiss: vi.fn()
}
const Provider = defineComponent({
setup(_, { slots }) {
provide(SnackbarToastKey, api)
return () => slots.default?.()
}
})
render(Provider, {
slots: { default: () => h(Consumer) }
})
expect(screen.getByTestId('has-show').textContent).toBe('function')
expect(screen.getByTestId('has-dismiss').textContent).toBe('function')
})
})

View File

@@ -0,0 +1,32 @@
import type { InjectionKey } from 'vue'
import { inject } from 'vue'
export interface ShowSnackbarOptions {
shortcut?: string
duration?: number
actionLabel?: string
onAction?: () => void
}
export interface SnackbarToastItem extends ShowSnackbarOptions {
id: string
message: string
}
export interface SnackbarToastApi {
show(message: string, options?: ShowSnackbarOptions): string
dismiss(id: string): void
}
export const SnackbarToastKey: InjectionKey<SnackbarToastApi> =
Symbol('SnackbarToastApi')
export function useSnackbarToast(): SnackbarToastApi {
const api = inject(SnackbarToastKey, null)
if (!api) {
throw new Error(
'useSnackbarToast() must be called within <SnackbarToastProvider>.'
)
}
return api
}

View File

@@ -1,195 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetItem } from './promotionPolicy'
import {
getPromotableWidgets,
isPreviewPseudoWidget,
isRecommendedWidget
} from './promotionPolicy'
function widget(
overrides: Partial<
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
function widgetItem(
nodeType: string,
widgetName: string,
overrides: Partial<IBaseWidget> = {}
): WidgetItem {
const node = { title: nodeType, id: 1, type: nodeType }
const w = fromPartial<IBaseWidget>({
name: widgetName,
computedDisabled: false,
...overrides
})
return [node, w]
}
describe('isPreviewPseudoWidget', () => {
it('returns true for $$-prefixed widget names', () => {
expect(
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
).toBe(true)
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
})
it('returns true for serialize:false with type "preview"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: false, type: 'preview' })
)
).toBe(true)
})
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
expect(
isPreviewPseudoWidget(
widget({
name: 'videopreview',
type: 'preview',
options: { serialize: false }
})
)
).toBe(true)
})
it('returns true for serialize:false with type "video"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'vid', serialize: false, type: 'video' })
)
).toBe(true)
})
it('returns true for serialize:false with type "audioUI"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'audio', serialize: false, type: 'audioUI' })
)
).toBe(true)
})
it('returns false for type "preview" when serialize is not false', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: true, type: 'preview' })
)
).toBe(false)
})
it('returns false for regular widgets', () => {
expect(
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
).toBe(false)
})
it('returns false for serialize:false with unknown type', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'text', serialize: false, type: 'customtext' })
)
).toBe(false)
})
})
describe('getPromotableWidgets', () => {
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
const node = new LGraphNode('PreviewImage')
node.type = 'PreviewImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for SaveImage nodes', () => {
const node = new LGraphNode('SaveImage')
node.type = 'SaveImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
const node = new LGraphNode('ImageInvert')
node.type = 'ImageInvert'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
})
describe('isRecommendedWidget', () => {
it('returns true for widgets on recommended node types', () => {
expect(isRecommendedWidget(widgetItem('CLIPTextEncode', 'text'))).toBe(true)
expect(isRecommendedWidget(widgetItem('LoadImage', 'image'))).toBe(true)
expect(isRecommendedWidget(widgetItem('SaveImage', 'filename'))).toBe(true)
expect(isRecommendedWidget(widgetItem('PreviewImage', 'anything'))).toBe(
true
)
})
it('returns true for seed widgets regardless of node type', () => {
expect(isRecommendedWidget(widgetItem('KSampler', 'seed'))).toBe(true)
expect(isRecommendedWidget(widgetItem('KSamplerAdvanced', 'seed'))).toBe(
true
)
})
it('returns false for non-recommended node and widget combinations', () => {
expect(isRecommendedWidget(widgetItem('KSampler', 'steps'))).toBe(false)
expect(isRecommendedWidget(widgetItem('VAEDecode', 'samples'))).toBe(false)
})
it('returns false when widget is computedDisabled', () => {
expect(
isRecommendedWidget(
widgetItem('CLIPTextEncode', 'text', { computedDisabled: true })
)
).toBe(false)
expect(
isRecommendedWidget(
widgetItem('KSampler', 'seed', { computedDisabled: true })
)
).toBe(false)
})
})

View File

@@ -1,69 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
export type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
return true
return false
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
return {
name: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'IMAGE_PREVIEW',
options: { serialize: false },
serialize: false,
y: 0,
computedDisabled: false
}
}
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
const widgets = [...(node.widgets ?? [])]
const hasCanvasPreviewWidget = widgets.some(
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
const supportsVirtualPreview = supportsVirtualCanvasImagePreview(node)
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
widgets.push(createVirtualCanvasImagePreviewWidget())
}
return widgets
}

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