Compare commits

...

38 Commits

Author SHA1 Message Date
dante01yoon
25ac20ffb9 fix: resolve CI failures after main merge
- baseTooltip.spec.ts: use @e2e/ alias for ComfyPage import
- OutputSlot.test.ts: stub BaseTooltip and mock tooltipsEnabled/tooltipDelay
- NodeWidgets.test.ts: stub BaseTooltip so widget slot renders in tests
2026-04-17 15:58:44 +09:00
dante01yoon
926d48f772 Merge remote-tracking branch 'origin/feat/tooltip-reka-ui-component' into feat/tooltip-migrate-node-tooltips
# Conflicts:
#	src/components/TopMenuSection.vue
#	src/components/queue/JobHistoryActionsMenu.test.ts
#	src/components/queue/JobHistoryActionsMenu.vue
#	src/components/queue/QueueOverlayActive.test.ts
#	src/components/queue/QueueOverlayHeader.test.ts
#	src/components/queue/job/JobFilterActions.vue
#	src/components/ui/tooltip/BaseTooltip.vue
#	src/components/ui/tooltip/tooltip.variants.ts
2026-04-17 15:15:44 +09:00
dante01yoon
b593496e3e Merge remote-tracking branch 'origin/main' into feat/tooltip-migrate-node-tooltips
# Conflicts:
#	src/App.vue
#	src/components/TopMenuSection.vue
#	src/components/queue/JobHistoryActionsMenu.test.ts
#	src/components/queue/QueueOverlayActive.test.ts
#	src/components/queue/QueueOverlayHeader.test.ts
#	src/renderer/extensions/vueNodes/components/NodeHeader.test.ts
#	src/renderer/extensions/vueNodes/components/NodeWidgets.vue
2026-04-17 13:35:11 +09:00
Dante
2d50cc2d76 feat: show success toast after ComfyHub publish (#11316)
## Summary

Adds a success toast in the ComfyHub publish flow so users get explicit
confirmation that the workflow was published before the dialog closes.

## Changes

- **What**: `ComfyHubPublishDialog.handlePublish()` calls `toast.add({
severity: 'success', ... })` after `submitToComfyHub()` resolves and
before `onClose()` runs. Adds two i18n keys (`publishSuccessTitle`,
`publishSuccessDescription`) and an assertion in the existing
success-path test.

## Review Focus

- This is the lightweight stop-gap discussed in [Slack
thread](https://comfy-organization.slack.com/archives/C0AEPRS8N74/p1776370871654139?thread_ts=1776362591.237159&cid=C0AEPRS8N74)
while the larger published-state design is still pending phase-2 work.
Symmetric with the existing `publishFailedTitle/Description` error
toast.
- `submitToComfyHub` is synchronous (asset uploads happen inside it), so
a successful resolve means the workflow is live.
- `<Toast>` is mounted in `GlobalToast.vue`, so it persists after
`onClose()` destroys the dialog.

## Screenshots (if applicable)
<img width="1135" height="634" alt="Screenshot 2026-04-17 at 8 11 34 AM"
src="https://github.com/user-attachments/assets/a71400a7-2055-4c2a-a761-9298cfa24e9a"
/>

n/a — toast text only.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11316-feat-show-success-toast-after-ComfyHub-publish-3446d73d365081a7bbb3ca29ca3bb618)
by [Unito](https://www.unito.io)
2026-04-16 23:32:36 +00:00
Kelly Yang
89c11c9aa9 test: add unit test suite for apps/desktop-ui (#11275)
## Summary

This is a follow-up PR of #11102

| Requirement | Status | Implementation |
| :--- | :--- | :--- |
| Add vitest configuration for desktop-ui workspace |  Done | Added
`apps/desktop-ui/vitest.config.mts` with `happy-dom` environment, `@`
alias, and `setupFiles` pointing to `src/test/setup.ts` (registers
`@testing-library/jest-dom` matchers) |
| Add test:unit script to package.json |  Done | Added `"test:unit":
"vitest run --config vitest.config.mts"` to
`apps/desktop-ui/package.json` |
| stores/maintenanceTaskStore.ts |  Done | 34 tests covering task state
machine, IPC integration, executeTask flow, and error handling via
`@pinia/testing` |
| utils/electronMirrorCheck.ts |  Done | 5 tests covering URL
validation, canAccessUrl delegation, and true/false return logic |
| utils/refUtil.ts (useMinLoadingDurationRef) |  Done | 7 tests
covering initial state, timing behavior using `vi.useFakeTimers`, and
computed ref input |
| utils/envUtil.ts |  Done | 7 tests covering electronAPI detection and
fallback behavior |
| constants/desktopDialogs.ts |  Done | 8 tests covering dialog
structure and field contracts |
| constants/desktopMaintenanceTasks.ts |  Done | 5 tests covering
`pythonPackages.execute` success/failure return values, and URL-opening
tasks calling `window.open` |
| composables/bottomPanelTabs/useTerminal.ts |  Done | 7 tests covering
key event handler: Ctrl/Meta+C with/without selection, Ctrl/Meta+V,
non-keydown events, and unrelated keys — mocked xterm with Vitest
v4-compatible function constructors |
| composables/bottomPanelTabs/useTerminalBuffer.ts |  Done | 2 tests
for `copyTo`: verifies serialized buffer content is written to
destination terminal |
| utils/validationUtil.ts |  Skipped | The current file contains only a
`ValidationState` enum with no logic. There is no behavior to test
without writing a change-detector test (asserting enum values), which
violates project testing guidelines |

**Additional config changes (not in issue but required to make tests
work):**

| Change | Reason |
| :--- | :--- |
| Added `"vitest.config.mts"` to `apps/desktop-ui/tsconfig.json` include
| Required for ESLint's TypeScript parser to process the config file
without a parsing error |
| Removed 6 redundant test devDependencies from
`apps/desktop-ui/package.json` | `vitest`, `@testing-library/*`,
`@pinia/testing`, `happy-dom` are already declared at the root and
hoisted by pnpm — re-declaring them in the sub-package is unnecessary |

## Changes
- Add vitest.config.mts with happy-dom environment and path aliases
- Add src/test/setup.ts to register @testing-library/jest-dom matchers
- Add test:unit script to package.json
- Add vitest.config.mts to tsconfig.json include for ESLint
compatibility
- Remove redundant test devDependencies already declared at root
- Add 132 tests across 16 files covering stores, composables, utils, and
constants

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test- and config-only changes; main risk is CI/build instability from
new Vitest configuration or brittle mocks, with no runtime behavior
changes shipped to users.
> 
> **Overview**
> Adds a dedicated Vitest setup for `apps/desktop-ui` (new
`vitest.config.mts` using `happy-dom`, aliases, and a `jest-dom` setup
file) and wires it into the workspace via a new `test:unit` script plus
`tsconfig.json` inclusion.
> 
> Introduces a broad set of new unit tests for desktop UI components,
composables, constants, utilities, and the `maintenanceTaskStore`
(mocking Electron/PrimeVue/Xterm as needed) to validate state
transitions, validation flows, and key UI behaviors without changing
production logic.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
0a96ffb37c. 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-11275-test-add-unit-test-suite-for-apps-desktop-ui-3436d73d36508145ae1fe99ec7a3a4fa)
by [Unito](https://www.unito.io)
2026-04-16 22:20:00 +00:00
Kelly Yang
29d6263fb9 test: add Preview3D execution flow E2E tests (#11014)
## Summary
Adds Playwright coverage for `Preview3D execution` and persistence :
real queue execution against a `Load3D → Preview3D` workflow, plus `save
/ full reload / reopen` from the sidebar.

## What these tests do
**Fixture** (every test)
Turns on Vue Nodes, uses the sidebar for workflows, loads a Load3D →
Preview3D workflow, waits for nodes, then clears saved workflows after
the test so runs stay isolated.

**Test 1 — execution updates Preview3D**
Uploads `cube.obj`(the existing test file in the merged version) to
Load3D, runs `Queue Prompt`, then checks that Preview3D’s model_file and
Last Time Model File match and the canvas has non-zero size. No 3D
screenshots (GPU flakiness).

**Test 2 — persistence after reload**
Same upload + queue, then saves the workflow, reloads the page,
re-applies the same UI settings, opens the saved workflow, and checks
the same model path and camera state (with a small numeric tolerance).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new slow, WebGL-dependent E2E tests and fixtures, which can
increase CI runtime and introduce flakiness due to timing/graphics
variability, but does not change production logic.
> 
> **Overview**
> Adds a new `Load3D → Preview3D` workflow asset and a dedicated
Playwright fixture (`Preview3DPipelineFixture`) to drive real queue
execution, upload a 3D model, and interact with the 3D canvases (orbit
drags) while asserting `model_file`/`Last Time Model File` and camera
state via node properties.
> 
> Introduces camera-state comparison helpers with explicit numeric
tolerances, and adds a new `preview3dExecution.spec.ts` suite that
validates (1) Preview3D updates from execution output and (2) model +
camera persistence across save, full page reload, and reopening the
workflow from the sidebar.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5f54b0f650. 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-11014-test-add-Preview3D-execution-flow-E2E-tests-33e6d73d3650811fa298c364ae196606)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-16 18:08:04 -04:00
jaeone94
a1e6fb36d2 refactor: harden ChangeTracker lifecycle with self-defending API (#10816)
## Summary

Harden the `ChangeTracker` lifecycle to eliminate the class of bugs
where an inactive workflow's tracker silently captures the wrong graph
state. Renames `checkState()` to `captureCanvasState()` with a
self-defending assertion, introduces `deactivate()` and
`prepareForSave()` lifecycle methods, and closes a latent undo-history
corruption bug discovered during code review.

## Background

ComfyUI supports multiple workflows open as tabs, but only one canvas
(`app.rootGraph`) exists at a time. When the user switches tabs, the old
workflow's graph is unloaded and the new one is loaded into this shared
canvas.

The old `checkState()` method serialized `app.rootGraph` into
`activeState` to track changes for undo/redo. It had no awareness of
*which* workflow it belonged to -- if called on an inactive tab's
tracker, it would capture the active tab's graph data and silently
overwrite the inactive workflow's state. This caused permanent data loss
(fixed in PR #10745 with caller-side `isActive` guards).

The caller-side guards were fragile: every new call site had to remember
to add the guard, and forgetting would reintroduce the same silent data
corruption. Additionally, `beforeLoadNewGraph` only called `store()`
(viewport/outputs) without `checkState()`, meaning canvas state could be
stale if a tab switch happened without a preceding mouseup event.

### Before (fragile)

```
saveWorkflow(workflow):
  if (isActive(workflow))              <-- caller must remember this guard
    workflow.changeTracker.checkState()      <-- name implies "read", actually writes
  ...

beforeLoadNewGraph():
  activeWorkflow.changeTracker.store()      <-- only saves viewport, NOT graph state
```

### After (self-defending)

```
saveWorkflow(workflow):
  workflow.changeTracker.prepareForSave()   <-- handles active/inactive internally
  ...

beforeLoadNewGraph():
  activeWorkflow.changeTracker.deactivate() <-- captures graph + viewport together
```

## Changes

- Rename `checkState` to `captureCanvasState` with active-tracker
assertion
- Add `deactivate()` and `prepareForSave()` lifecycle methods
- Fix undo-history corruption: `captureCanvasState()` guarded by
`_restoringState`
- Fix viewport regression during undo: `deactivate()` skips
`captureCanvasState()` during undo/redo but always calls `store()` to
preserve viewport (regression from PR #10247)
- Log inactive tracker warnings unconditionally at warn level (not
DEV-only)
- Deprecated `checkState()` wrapper for extension compatibility
- Rename `checkState` to `captureCanvasState` in
`useWidgetSelectActions` composable
- Add `appModeStore.ts` to manual call sites documentation
- Add `checkState()` deprecation note to architecture docs
- Add 16 unit tests covering all guard conditions, lifecycle methods,
and undo behavior
- Add E2E test: "Undo preserves viewport offset"

## New ChangeTracker Public API

| Method | Caller | Purpose |
|--------|--------|---------|
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots
canvas into activeState, pushes undo. Asserts active tracker. |
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()`
(skipped during undo/redo) + `store()`. Freezes state for tab switch. |
| `prepareForSave()` | Save paths only | Active: `captureCanvasState()`.
Inactive: no-op. |
| `checkState()` | **Deprecated** -- extensions only | Wrapper that
delegates to `captureCanvasState()` with deprecation warning. |
| `store()` | Internal to `deactivate()` | Saves viewport, outputs,
subgraph navigation. |
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs,
subgraph navigation. |
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks as
"clean"). |

## Test plan

- [x] Unit tests: 16 tests covering all guard conditions, state capture,
undo queue behavior
- [x] E2E test: "Undo preserves viewport offset" verifies no viewport
drift on undo
- [x] E2E test: "Prevents captureCanvasState from corrupting workflow
state during tab switch"
- [x] Existing E2E: "Closing an inactive tab with save preserves its own
content"
- [ ] Manual: rapidly switch tabs during undo/redo, verify no viewport
drift
- [ ] Manual: verify extensions calling `checkState()` see deprecation
warning in console
2026-04-16 12:54:12 +00:00
jaeone94
394e36984f fix: re-sync collapsed node slot positions after subgraph fitView (#11240)
## Summary

Fix collapsed node connection links rendering at wrong positions when
entering a subgraph for the first time. `fitView()` (added in #10995)
changes canvas scale/offset, invalidating cached slot positions for
collapsed nodes.

## Changes

- **What**: Schedule `requestSlotLayoutSyncForAllNodes()` on the next
frame after `fitView()` in `restoreViewport()` so collapsed node slot
positions are re-measured against the updated transform. Inner RAF
guarded against mid-frame graph changes.
- **Test coverage**:
- Unit tests in `subgraphNavigationStore.viewport.test.ts` verify the
RAF chain calls `requestSlotLayoutSyncForAllNodes` after `fitView`, and
skip the re-sync when the active graph changes between frames.
- E2E screenshot test (`@screenshot` tag) validates correct link
rendering on first subgraph entry using a new fixture with a
pre-collapsed inner node.

## Review Focus

The nested `requestAnimationFrame` is intentional: the outer RAF runs
`fitView()`, which updates `ds.scale`/`ds.offset` and triggers a CSS
transform update on `TransformPane`. The inner RAF ensures the DOM has
reflowed with the new transform before
`requestSlotLayoutSyncForAllNodes()` measures `getBoundingClientRect()`
on slot elements.

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-16 12:38:01 +00:00
Dante
19fff29204 test: backfill e2e coverage gaps for toolkit widgets, minimap, mask editor, painter (#11183)
## Summary

Backfills missing e2e test coverage identified in the [FixIt
Burndown](https://www.notion.so/comfy-org/FixIt-Burndown-32e6d73d365080609a81cdc9bc884460)
audit. Adds 39 new behavioral tests across 5 spec files with zero
test-code overlap.

## Changes

- **What**: New e2e specs for Image Crop (6 tests) and Curve Widget (6
tests). Deepened coverage for Minimap (+6), Mask Editor (+10), Painter
(+11).
- **New fixtures**: `curve_widget.json`, updated
`image_crop_widget.json`

## Test Inventory

| Spec | New tests | Coverage area |
|---|---|---|
| `imageCrop.spec.ts` | 6 | Empty state, bounding box inputs, ratio
selector/presets, lock toggle, programmatic value update |
| `curveWidget.spec.ts` | 6 | SVG render, click-to-add point,
drag-to-reshape, Ctrl+click remove, interpolation mode switch, min-2
guard |
| `minimap.spec.ts` | +6 | Click-to-pan, drag-to-pan, zoom viewport
shrink, node count changes, workflow reload, pan state reflection |
| `maskEditor.spec.ts` | +10 | Brush drawing, undo/redo, clear, cancel,
invert, Ctrl+Z, tool panel/switching, brush settings, save with mock,
eraser |
| `painter.spec.ts` | +11 | Clear, eraser, control visibility toggle,
brush size slider, stroke width comparison, canvas dimensions,
background color, multi-stroke accumulate, color picker, opacity,
partial erase |

## Review Focus

- Mask editor tests use `.maskEditor_toolPanelContainer` class selectors
— may need test-id hardening later
- Painter slider interaction tests could be flaky if slider layout
changes
- All canvas pixel-count assertions use `expect.poll()` with timeouts
for reliability

## Test plan
- [ ] CI passes all new/modified specs
- [ ] No duplicate coverage with existing tests (verified via grep
before writing)
- [ ] No `waitForTimeout` usage (confirmed)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11183-test-backfill-e2e-coverage-gaps-for-toolkit-widgets-minimap-mask-editor-painter-3416d73d3650819ca33edd1f27b9651a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-16 09:48:13 +00:00
Johnpaul Chiwetelu
b3b895a2a9 refactor(test): use canvasOps.clickEmptySpace in copyPaste spec (#10991)
## Summary

Replace two hardcoded blank-canvas click positions in
`copyPaste.spec.ts` with the existing
`comfyPage.canvasOps.clickEmptySpace()` helper.

## Changes

- **What**: Both `{ x: 50, y: 500 }` click literals in the `Copy paste
node, image paste onto LoadImage, image paste on empty canvas` test now
use `canvasOps.clickEmptySpace()` (which wraps
`DefaultGraphPositions.emptySpaceClick = { x: 35, y: 31 }`). Redundant
`await nextFrame()` calls dropped — the helper already awaits a frame
internally.

## Review Focus

Draft PR — need CI to confirm `(35, 31)` is a valid blank-canvas click
for the `load_image_with_ksampler` workflow used by this test. The
workflow places `LoadImage` at `[50, 50]` and `KSampler` at `[500, 50]`,
so `(35, 31)` should be clear of both. Locally the test was already
failing on `main` (pre-existing, unrelated), so CI is the source of
truth here. If CI fails, the fallback is to add a dedicated named
constant `emptyCanvasClick: { x: 50, y: 500 }` to
`DefaultGraphPositions` as originally proposed in the issue.

Fixes #10330

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10991-refactor-test-use-canvasOps-clickEmptySpace-in-copyPaste-spec-33d6d73d3650817aa3ccea44cb48c0ae)
by [Unito](https://www.unito.io)
2026-04-16 09:44:06 +00:00
Dante
e5c81488e4 fix: include focusMode in splitter refresh key to prevent panel resize (#11295)
## Summary

When the properties panel is open, toggling focus mode on then off
causes the panel to resize unexpectedly. The root cause is that
`splitterRefreshKey` in `LiteGraphCanvasSplitterOverlay.vue` does not
include `focusMode`, so the PrimeVue Splitter component instance is
reused across focus mode transitions and restores stale panel sizes from
localStorage.

Fix: add `focusMode` to `splitterRefreshKey` so the Splitter is
recreated when focus mode toggles.

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for focus mode toggle resizing properties
panel` | 🔴 Red | Proves the test catches the bug |
| `fix: include focusMode in splitter refresh key to prevent panel
resize` | 🟢 Green | Proves the fix resolves the bug |

## demo

### AS IS


https://github.com/user-attachments/assets/95f6a9e3-e4c7-4aba-8e17-0eee11f70491


### TO BE


https://github.com/user-attachments/assets/595eafcd-6a80-443d-a6f3-bb7605ed0758



## Test Plan

- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] E2E regression test added in
`browser_tests/tests/focusMode.spec.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11295-fix-include-focusMode-in-splitter-refresh-key-to-prevent-panel-resize-3446d73d365081b7bc3ac65338e17a8f)
by [Unito](https://www.unito.io)
2026-04-16 13:43:02 +09:00
Christian Byrne
5c07198acb fix: add validation to E2E coverage shard merge (#11290)
## Summary

Add a validation step after merging E2E coverage shards to detect data
loss and improve observability.

## Changes

- **What**: After `lcov -a` merges shard LCOVs, a new step parses merged
+ per-shard stats (source files, lines hit) and writes them to the
**GitHub Actions job summary** as a markdown table. If merged `LH`
(lines hit) is less than any single shard's `LH`, an error annotation is
emitted — this invariant should never be violated since merging should
only add coverage.
- Helps diagnose the 68% → 42% E2E coverage drop after sharding was
introduced.

## Review Focus

The step is informational — it emits `::error::` annotations but does
not `exit 1`, so it won't block the workflow. We can make it a hard
failure once we're confident the merge is stable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11290-fix-add-validation-to-E2E-coverage-shard-merge-3446d73d365081c8a942e92deba92006)
by [Unito](https://www.unito.io)
2026-04-15 21:39:51 -07:00
Terry Jia
6fb90b224d fix(load3d): restore missed hover state when viewer init is async (#11265)
## Summary
followup https://github.com/Comfy-Org/ComfyUI_frontend/pull/9520
mouseenter fires before load3d is created during async init
(getLoad3dAsync), so the STATUS_MOUSE_ON_VIEWER flag is never set.
This causes isActive() to return false after INITIAL_RENDER_DONE,
stopping the animation loop from calling controlsManager.update() and
making OrbitControls unresponsive on first open.

Track hover state in the composable and sync it to load3d after
creation.
2026-04-15 22:34:57 -04:00
pythongosssss
a8e1fa8bef test: add regression test for WEBP RIFF padding (#8527) (#11267)
## Summary

Add a regression test for #8527 (handle RIFF padding for odd-sized WEBP
chunks). The fix added + (chunk_length % 2) to the chunk-stride
calculation in getWebpMetadata so EXIF chunks following an odd-sized
chunk are still located correctly. There was no existing unit test
covering getWebpMetadata, so without a regression test the fix could
silently break in a future
  refactor. 

## Changes

- **What**: 
- New unit test file src/scripts/pnginfo.test.ts covering
getWebpMetadata's RIFF chunk traversal.
- Helpers build a minimal in-memory WEBP with one VP8 chunk of
configurable length followed by an EXIF chunk encoding workflow:<json>.
- Odd-length case (regression for #8527): without the % 2 padding
adjustment, the parser walks one byte short and returns {}.
- Even-length case: guards against an over-correction that always adds
1.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11267-test-add-regression-test-for-WEBP-RIFF-padding-8527-3436d73d36508117a66edf3cb108ded0)
by [Unito](https://www.unito.io)
2026-04-15 18:14:49 +00:00
pythongosssss
83ceef8cb3 test: add regression test for non-string serverLogs (#8460) (#11268)
## Summary

Add a regression test for #8460 (handle non-string `serverLogs` in error
report). The fix added `typeof error.serverLogs === 'string' ? ... :
JSON.stringify(...)` in `errorReportUtil.ts` so object-shaped logs no
longer render as `[object Object]`. There was no existing unit test for
`generateErrorReport`, so this regression could silently return.

## Changes

- **What**: New unit test file `src/utils/errorReportUtil.test.ts`
covering `generateErrorReport`'s `serverLogs` rendering.
- String case: verifies plain-string logs still appear verbatim and
`[object Object]` is absent.
- Object case (regression for #8460): verifies object logs are
JSON-stringified instead of coerced to `[object Object]`.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11268-test-add-regression-test-for-non-string-serverLogs-8460-3436d73d36508195a32fc559ab7ce5bb)
by [Unito](https://www.unito.io)
2026-04-15 18:14:17 +00:00
Christian Byrne
4885ef856c [chore] Update Comfy Registry API types from comfy-api@113318d (#11261)
## Automated API Type Update

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

- API commit: 113318d
- Generated on: 2026-04-15T04:26:33Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11261-chore-Update-Comfy-Registry-API-types-from-comfy-api-113318d-3436d73d3650816784d4efd98d6a665a)
by [Unito](https://www.unito.io)

Co-authored-by: bigcat88 <13381981+bigcat88@users.noreply.github.com>
2026-04-15 11:16:10 -07:00
Christian Byrne
873a75d607 test: add unit tests for usePainter composable (#11137)
## Summary

Add 25 behavioral unit tests for `usePainter` composable, bringing
coverage from 0% to ~35% lines / ~57% functions.

## Changes

- **What**: New test file `src/composables/painter/usePainter.test.ts`
covering widget sync, settings persistence, canvas sizing, brush display
scaling, serialization, restore, pointer event guards, and cursor
visibility.

## Review Focus

- Mock patterns: singleton factory mocks for stores, wrapper component
for lifecycle hooks
- Test coverage prioritization: focused on mount-time sync, reactive
watchers, and computed behavior rather than canvas pixel output

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11137-test-add-unit-tests-for-usePainter-composable-33e6d73d36508147bde7e9c349c743ca)
by [Unito](https://www.unito.io)
2026-04-15 11:13:31 -07:00
pythongosssss
ecb6fbe8fb test: Add waitForWorkflowIdle & remove redundant nextFrame (#11264)
## Summary

More cleanup and reliability

## Changes

- **What**: 
- Add wait for idle
- Remove redundant nextFrames

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11264-test-Add-waitForWorkflowIdle-remove-redundant-nextFrame-3436d73d3650812c837ac7503ce0947b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-15 16:52:41 +00:00
Alexander Brown
52ccd9ed1a refactor: internalize nextFrame() into fixture/helper methods (#11166)
## Summary

Internalize `nextFrame()` calls into fixture/helper methods so spec
authors don't need to remember to call it after common operations.
`nextFrame()` waits for one `requestAnimationFrame` (~16ms) — an extra
call is always safe, making this a low-risk refactor.

## Changes

### Phase 1: `SettingsHelper.setSetting()`
`setSetting()` now calls `nextFrame()` internally. Removed 15 redundant
calls across 7 files.

### Phase 2: `CommandHelper.executeCommand()`
`executeCommand()` now calls `nextFrame()` internally. Removed 15
redundant calls across 7 files, including the now-redundant call in
`AppModeHelper.toggleAppMode()`.

### Phase 3: `WorkflowHelper.loadGraphData()`
New helper wraps `page.evaluate(loadGraphData)` + `nextFrame()`.
Migrated `SubgraphHelper.serializeAndReload()` and `groupNode.spec.ts`.

### Phase 4: `NodeReference` cleanup
Removed redundant `nextFrame()` from `copy()`, `convertToGroupNode()`,
`resizeNode()`, `dragTextEncodeNode2()`, and
`convertDefaultKSamplerToSubgraph()`. Removed 6 spec-level calls after
`node.click('title')`.

### Phase 5: `KeyboardHelper.press()` and `delete()`
New convenience methods that press a key and wait one frame. Converted
40 `canvas.press(key)` + `nextFrame()` pairs across 13 spec files.

### Phase 6: `ComfyPage.expectScreenshot()`
New helper combines `nextFrame()` + `toHaveScreenshot()`. Converted 45
pairs across 12 spec files.

## Total impact
- **~130 redundant `nextFrame()` calls eliminated** across ~35
spec/helper files
- **3 new helper methods** added (`loadGraphData`, `press`/`delete`,
`expectScreenshot`)
- **2 existing methods** enhanced (`setSetting`, `executeCommand`)

## What was NOT changed
- `performance.spec.ts` frame-counting loops (intentional)
- `ComfyMouse.ts` / `CanvasHelper.ts` (already internalized)
- `SubgraphHelper.packAllInteriorNodes()` (deliberate orchestration)
- Builder helpers (already internalized)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11166-refactor-internalize-nextFrame-into-fixture-helper-methods-33f6d73d3650817bb5f6fb46e396085e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-15 15:25:47 +00:00
Kelly Yang
92ad6fc798 test: address review nits for image compare E2E (#11260)
## Summary
A follow-up PR of #11196.

| # | Nit | Action | Reason |
| :--- | :--- | :--- | :--- |
| 1 | Replace `page.on('pageerror')` with request-wait | **Left as-is**
| The `pageErrors` array is an accumulator checked at the end via
`expect(pageErrors).toHaveLength(0)` – the goal is to assert that broken
image URLs don't surface as uncaught JS exceptions during the test run.
A request-wait can't substitute for that behavioral assertion, so the
listener pattern is intentional here. |
| 2 | Move helpers to a `vueNodes.getImageCompareHelper()` subclass |
**Left as-is** | Helpers such as `setImageCompareValue` and
`moveToPercentage` are only used in this file, making local
encapsulation enough. Extracting them to a page object would increase
the file/interface surface area and violate YAGNI; additionally,
`AGENTS.md` clearly states to "minimize the exported values of each
module. |
| 3 | Use `TestIds` enum for test ID strings | **Fixed** – added
`imageCompare` section to `TestIds` in `selectors.ts`; replaced all 8
inline string IDs in `imageCompare.spec.ts` with
`TestIds.imageCompare.*` references | The project already has a
`TestIds` convention for centralizing test IDs. Inline strings create
drift risk between the Vue component and the test file. |
| 4 | Move `expect.poll` bounding box check to helper/page object |
**Left as-is** | This logic already lives inside `moveToPercentage`,
which is a local helper. Moving it further to a page object is the same
refactor as #2 above. |
| 5 | Remove `// ---` style section header comments | **Fixed** –
removed all 8 divider blocks from `imageCompare.spec.ts` | Consistent
with project guidelines and your explicit preference. Test names already
describe what each block does. |
| 6 | Name magic numbers `400` and `350` | **Fixed** – introduced
`minWidth = 400` and `minHeight = 350` constants in the test |
Descriptive names make the constraint self-documenting and easier to
update if the workflow asset changes. |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E test code and shared
selector constants, with no production logic impacted.
> 
> **Overview**
> **E2E Image Compare tests now use centralized selectors.** Adds an
`imageCompare` section to `TestIds` and updates `imageCompare.spec.ts`
to reference `TestIds.imageCompare.*` instead of inline `data-testid`
strings.
> 
> Cleans up the spec by removing divider comments and naming the minimum
size magic numbers (`minWidth`, `minHeight`) used in the node sizing
assertion.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ece25be5cc. 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-11260-test-address-review-nits-for-image-compare-E2E-3436d73d365081a69cacc1fff390035a)
by [Unito](https://www.unito.io)
2026-04-15 10:50:44 -04:00
pythongosssss
06686a1f50 test: App mode - additional app mode coverage (#11194)
## Summary

Adds additional test coverage for empty state/welcome screen/connect
outputs/vue nodes auto switch

## Changes

- **What**: 
- add tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11194-test-App-mode-additional-app-mode-coverage-3416d73d365081ca91d0ed61de19f840)
by [Unito](https://www.unito.io)
2026-04-15 11:42:22 +00:00
jaeone94
693b8383d6 fix: missing-asset correctness follow-ups from #10856 (#11233)
Follow-up to #10856. Four correctness issues and their regression tests.

## Bugs fixed

### 1. ErrorOverlay model count reflected node selection

`useErrorGroups` exposed `filteredMissingModelGroups` under the public
name `missingModelGroups`. `ErrorOverlay.vue` read that alias to compute
its model count label, so selecting a node shrank the overlay total. The
overlay must always show the whole workflow's errors.

Exposed both shapes explicitly: `missingModelGroups` /
`missingMediaGroups` (unfiltered totals) and
`filteredMissingModelGroups` / `filteredMissingMediaGroups`
(selection-scoped). `TabErrors.vue` destructures the filtered variant
with an alias.


Before 


https://github.com/user-attachments/assets/eb848c5f-d092-4a4f-b86f-d22bb4408003

After 


https://github.com/user-attachments/assets/75e67819-c9f2-45ec-9241-74023eca6120



### 2. Bypass → un-bypass dropped url/hash metadata

Realtime `scanNodeModelCandidates` only reads widget values, so
un-bypass produced a fresh candidate without the url that
`enrichWithEmbeddedMetadata` had previously attached from
`graphData.models`. `MissingModelRow`'s download/copy-url buttons
disappeared after a bypass/un-bypass cycle.

Added `enrichCandidateFromNodeProperties` that copies
`url`/`hash`/`directory` from the node's own `properties.models` — which
persists across mode toggles — into each scanned candidate. Applied to
every call site of the per-node scan. A later fix in the same branch
also enforces directory agreement to prevent a same-name /
different-directory collision from stamping the wrong metadata.

Before 


https://github.com/user-attachments/assets/39039d83-4d55-41a9-9d01-dec40843741b

After 


https://github.com/user-attachments/assets/047a603b-fb52-4320-886d-dfeed457d833



### 3. Initial full scan surfaced interior errors of a muted/bypassed
subgraph container

`scanAllModelCandidates`, `scanAllMediaCandidates`, and the JSON-based
missing-node scan only check each node's own mode. Interior nodes whose
parent container was bypassed passed the filter.

Added `isAncestorPathActive(rootGraph, executionId)` to
`graphTraversalUtil` and post-filter the three pipelines in `app.ts`
after the live rootGraph is configured. The filter uses the execution-ID
path (`"65:63"` → check node 65's mode) so it handles both
live-scan-produced and JSON-enrichment-produced candidates.

Before


https://github.com/user-attachments/assets/3032d46b-81cd-420e-ab8e-f58392267602

After 


https://github.com/user-attachments/assets/02a01931-951d-4a48-986c-06424044fbf8




### 4. Bypassed subgraph entry re-surfaced interior errors

`useGraphNodeManager` replays `graph.onNodeAdded` for each existing
interior node when the Vue node manager initializes on subgraph entry.
That chain reached `scanSingleNodeErrors` via
`installErrorClearingHooks`' `onNodeAdded` override. Each interior
node's own mode was active, so the caller guards passed and the scan
re-introduced the error that the initial pipeline had correctly
suppressed.

Added an ancestor-activity gate at the top of `scanSingleNodeErrors`,
the single entry point shared by paste, un-bypass, subgraph entry, and
subgraph container activation. A later commit also hardens this guard
against detached nodes (null execution ID → skip) and applies the same
ancestor check to `isCandidateStillActive` in the realtime verification
callback.

Before


https://github.com/user-attachments/assets/fe44862d-f1d6-41ed-982d-614a7e83d441

After


https://github.com/user-attachments/assets/497a76ce-3caa-479f-9024-4cd0f7bd20a4



## Tests

- 6 unit tests for `isAncestorPathActive` (root, active,
immediate-bypass, deep-nested mute, unresolvable ancestor, null
rootGraph)
- 4 unit tests for `enrichCandidateFromNodeProperties` (enrichment,
no-overwrite, name mismatch, directory mismatch)
- 1 unit test for `scanSingleNodeErrors` ancestor guard (subgraph entry
replaying onNodeAdded)
- 2 unit tests for `useErrorGroups` dual export + ErrorOverlay contract
- 4 E2E tests:
- ErrorOverlay model count stays constant when a node is selected (new
fixture `missing_models_distinct.json`)
- Bypass/un-bypass cycle preserves Copy URL button (uses
`missing_models_from_node_properties`)
- Loading a workflow with bypassed subgraph suppresses interior missing
model error (new fixture `missing_models_in_bypassed_subgraph.json`)
- Entering a bypassed subgraph does not resurface interior missing model
error (shares the above fixture)

`pnpm typecheck`, `pnpm lint`, 206 related unit tests passing.

## Follow-up

Several items raised by code review are deferred as pre-existing tech
debt or scope-avoided refactors. Tracked via comments on #11215 and
#11216.

---
Follows up on #10856.
2026-04-15 10:58:24 +00:00
GitHub Action
a9e53b997c [automated] Apply ESLint and Oxfmt fixes 2026-03-27 13:50:45 +00:00
dante01yoon
3aef8da214 fix(test): drop fragile z-index assertion from tooltip e2e test
getComputedStyle().zIndex returns "auto" in CI because Tailwind CSS
z-[1700] is not resolved to a numeric value in the CI build environment.
2026-03-27 22:47:42 +09:00
dante01yoon
5b2f4b303a test: add e2e regression tests for BaseTooltip migration 2026-03-27 22:47:42 +09:00
dante01yoon
7bf2120cc5 fix: add stroke to tooltip arrow for visibility against same-color backgrounds
Arrow fill and action bar background are identical (rgb(23,23,24)),
making the arrow invisible. Add stroke-node-component-tooltip-border
to match the tooltip border and make the arrow visually distinct.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
dante01yoon
8cd39fc24f fix: use z-[1700] bracket syntax for tooltip z-index
z-1700 without brackets was not generating a CSS rule (evaluated as
z-index: auto). Use Tailwind arbitrary value syntax z-[1700] to
ensure the class is generated correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
dante01yoon
1065c3da23 fix: increase tooltip z-index to z-1700 for overlay visibility
Tooltips were hidden behind LiteGraphCanvasSplitterOverlay (z-999)
because TooltipPortal renders to <body> with only z-50. Align with
Popover content z-index (z-1700).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
dante01yoon
30e4b443d1 feat: align BaseTooltip with Figma design, add Storybook, fix as-child nesting
- Update tooltip variants: shadow-interface, leading-none, export FOR_STORIES
- Add keybind and showIcon props to BaseTooltip per Figma design spec
- Add comprehensive Storybook stories for all tooltip variants
- Fix Popover + BaseTooltip as-child nesting conflict in
  JobHistoryActionsMenu and JobFilterActions by moving BaseTooltip
  outside the Popover #button slot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
Dante
77105602c0 refactor: migrate buildTooltipConfig consumers to BaseTooltip (#10378)
PR 2 in the tooltip migration series. Migrates all `buildTooltipConfig`
consumers from PrimeVue `v-tooltip` directive to the new Reka UI
`BaseTooltip` component.

- Replace `v-tooltip` + `buildTooltipConfig` with `<BaseTooltip>`
wrapper in 8 component files
- Remove computed wrappers that only existed for tooltip config
- Delete `src/composables/useTooltipConfig.ts` (no remaining consumers)
- Update 7 test files: replace tooltip directive stubs with
`BaseTooltip` component stubs

1. **PR 1** (base): Add `BaseTooltip` component using Reka UI
2. **PR 2** (this): Migrate `buildTooltipConfig` consumers (small
variant)
3. PR 3: Migrate remaining PrimeVue `v-tooltip` usages (Style 1)
4. PR 4: Remove PrimeVue tooltip directive registration

- [x] `pnpm typecheck` passes
- [x] `pnpm lint:fix` passes
- [x] `pnpm format` passes
- [x] `pnpm test:unit` passes (529 files, 7022 tests)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10378-refactor-migrate-buildTooltipConfig-consumers-to-BaseTooltip-32a6d73d3650812980c4d8b259fb502c)
by [Unito](https://www.unito.io)
2026-03-27 22:47:42 +09:00
dante01yoon
5e8146267a refactor: align tooltip variants with Figma design system
Replace variant prop (default/small/large) with size prop (small/large)
to match Figma component properties. Remove default variant that was
replicating PrimeVue Aura styling. Extract shared styles into cva base.
2026-03-27 22:47:42 +09:00
dante01yoon
f8e2a81666 refactor: remove Storybook stories from foundation PR 2026-03-27 22:47:42 +09:00
dante01yoon
d429a10ea6 feat: add BaseTooltip component using Reka UI
Add foundational tooltip component with three variants (default, small,
large), four positioning sides, and configurable delay/disabled state.
Wrap App.vue in TooltipProvider for app-wide tooltip support. Include
Storybook stories for all variants, sides, disabled, and long text.
2026-03-27 22:47:42 +09:00
dante01yoon
82a0b59367 refactor: migrate node tooltips from PrimeVue to BaseTooltip
Replace PrimeVue v-tooltip directive with Reka UI BaseTooltip component
in NodeHeader, InputSlot, OutputSlot, and NodeWidgets. Remove
PrimeVue-specific tooltip config (createTooltipConfig, hideTooltipsGlobally,
tooltipsTemporarilyDisabled, global event listeners) from useNodeTooltips
composable, keeping only text-resolution functions and tooltipsEnabled.
Add tooltipDelay computed from LiteGraph.Node.TooltipDelay setting.
2026-03-24 10:59:32 +09:00
dante01yoon
f16214a719 refactor: migrate buildTooltipConfig consumers to BaseTooltip
Replace all PrimeVue v-tooltip directive usages that relied on
buildTooltipConfig with the new Reka UI BaseTooltip component.
Delete the now-unused useTooltipConfig composable.
2026-03-22 08:13:52 +09:00
dante01yoon
ae07b4d3f8 refactor: align tooltip variants with Figma design system
Replace variant prop (default/small/large) with size prop (small/large)
to match Figma component properties. Remove default variant that was
replicating PrimeVue Aura styling. Extract shared styles into cva base.
2026-03-21 21:36:22 +09:00
dante01yoon
2a0daf20da refactor: remove Storybook stories from foundation PR 2026-03-21 21:30:39 +09:00
dante01yoon
356a291d09 feat: add BaseTooltip component using Reka UI
Add foundational tooltip component with three variants (default, small,
large), four positioning sides, and configurable delay/disabled state.
Wrap App.vue in TooltipProvider for app-wide tooltip support. Include
Storybook stories for all variants, sides, disabled, and long text.
2026-03-21 20:02:36 +09:00
186 changed files with 8201 additions and 1497 deletions

View File

@@ -54,6 +54,33 @@ jobs:
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
exit 1
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: always()
uses: actions/upload-artifact@v6

View File

@@ -5,6 +5,7 @@
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},

View File

@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import StartupDisplay from '@/components/common/StartupDisplay.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
})
const ProgressBarStub = {
props: ['mode', 'value', 'showValue'],
template:
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
}
function renderDisplay(
props: {
progressPercentage?: number
title?: string
statusText?: string
hideProgress?: boolean
fullScreen?: boolean
} = {}
) {
return render(StartupDisplay, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: { ProgressBar: ProgressBarStub }
}
})
}
describe('StartupDisplay', () => {
describe('progressMode', () => {
it('renders indeterminate mode when progressPercentage is undefined', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'indeterminate'
)
})
it('renders determinate mode when progressPercentage is provided', () => {
renderDisplay({ progressPercentage: 50 })
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'determinate'
)
})
it('passes progressPercentage as value to the progress bar', () => {
renderDisplay({ progressPercentage: 75 })
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
})
})
describe('hideProgress', () => {
it('hides the progress bar when hideProgress is true', () => {
renderDisplay({ hideProgress: true })
expect(screen.queryByTestId('progress-bar')).toBeNull()
})
it('shows the progress bar by default', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar')).toBeDefined()
})
})
describe('title', () => {
it('renders the title text when provided', () => {
renderDisplay({ title: 'Loading...' })
expect(screen.getByText('Loading...')).toBeDefined()
})
it('does not render h1 when title is not provided', () => {
renderDisplay()
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
})
})
describe('statusText', () => {
it('renders statusText with data-testid attribute', () => {
renderDisplay({ statusText: 'Starting server' })
expect(screen.getByTestId('startup-status-text').textContent).toContain(
'Starting server'
)
})
it('does not render statusText element when not provided', () => {
renderDisplay()
expect(screen.queryByTestId('startup-status-text')).toBeNull()
})
})
})

View File

@@ -0,0 +1,208 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
checkUrlReachable: vi.fn()
}))
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
import UrlInput from '@/components/common/UrlInput.vue'
import { ValidationState } from '@/utils/validationUtil'
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue', 'blur'],
template: `<input
data-testid="url-input"
:value="modelValue"
:data-invalid="invalid"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur')"
/>`
}
const InputIconStub = {
template: '<span data-testid="input-icon" />'
}
const IconFieldStub = {
template: '<div><slot /></div>'
}
function renderUrlInput(
modelValue = '',
validateUrlFn?: (url: string) => Promise<boolean>
) {
return render(UrlInput, {
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
}
describe('UrlInput', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('initial validation on mount', () => {
it('stays IDLE when modelValue is empty on mount', async () => {
renderUrlInput('')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(true)
renderUrlInput('https://example.com')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets INVALID state when URL is not reachable on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://unreachable.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
})
})
describe('input handling', () => {
it('resets validation state to IDLE on user input', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://bad.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
const user = userEvent.setup()
await user.type(screen.getByTestId('url-input'), 'x')
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
it('strips whitespace from typed input', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
const input = screen.getByTestId('url-input')
await user.type(input, 'htt ps')
expect((input as HTMLInputElement).value).not.toContain(' ')
})
})
describe('blur handling', () => {
it('emits update:modelValue on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
expect(onUpdate).toHaveBeenCalled()
})
it('normalizes URL on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
const emittedUrl = onUpdate.mock.calls[0]?.[0]
expect(emittedUrl).toBe('https://example.com/')
})
})
describe('custom validateUrlFn', () => {
it('uses custom validateUrlFn when provided', async () => {
const customValidator = vi.fn().mockResolvedValue(true)
renderUrlInput('https://custom.example', customValidator)
await waitFor(() => {
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
})
expect(checkUrlReachable).not.toHaveBeenCalled()
})
})
describe('state-change emission', () => {
it('emits state-change when validation state changes', async () => {
const onStateChange = vi.fn()
vi.mocked(checkUrlReachable).mockResolvedValue(true)
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onState-change': onStateChange
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
await waitFor(() => {
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
})
})
})
})

View File

@@ -0,0 +1,112 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
getPlatform: vi.fn().mockReturnValue('win32')
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
te: () => false,
st: (_key: string, fallback: string) => fallback
}))
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import GpuPicker from '@/components/install/GpuPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} }
})
const HardwareOptionStub = {
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
emits: ['click'],
template:
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
}
function renderPicker(device: TorchDeviceType | null = null) {
return render(GpuPicker, {
props: { device },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
HardwareOption: HardwareOptionStub,
Tag: {
props: ['value'],
template: '<span data-testid="recommended-tag">{{ value }}</span>'
}
}
}
})
}
describe('GpuPicker', () => {
describe('recommended badge', () => {
it('shows recommended badge for nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('shows recommended badge for amd', () => {
renderPicker('amd')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('does not show recommended badge for cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge for unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge when no device is selected', () => {
renderPicker(null)
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
})
describe('selection state', () => {
it('marks nvidia as selected when device is nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
})
it('marks cpu as selected when device is cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
})
it('marks unsupported as selected when device is unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
})
it('no option is selected when device is null', () => {
renderPicker(null)
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
})
})
describe('gpu options on non-darwin platform', () => {
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
renderPicker(null)
expect(screen.getByTestId('NVIDIA')).toBeDefined()
expect(screen.getByTestId('AMD')).toBeDefined()
expect(screen.getByTestId('CPU')).toBeDefined()
expect(screen.getByTestId('Manual Install')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,223 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createI18n } from 'vue-i18n'
const mockValidateComfyUISource = vi.fn()
const mockShowDirectoryPicker = vi.fn()
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
validateComfyUISource: mockValidateComfyUISource,
showDirectoryPicker: mockShowDirectoryPicker
}))
}))
import MigrationPicker from '@/components/install/MigrationPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
install: {
migrationSourcePathDescription: 'Source path description',
migrationOptional: 'Migration is optional',
selectItemsToMigrate: 'Select items to migrate',
pathValidationFailed: 'Validation failed',
failedToSelectDirectory: 'Failed to select directory',
locationPicker: {
migrationPathPlaceholder: 'Enter path'
}
}
}
}
})
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue'],
template: `<input
data-testid="source-input"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>`
}
const CheckboxStub = {
props: ['modelValue', 'inputId', 'binary'],
emits: ['update:modelValue', 'click'],
template: `<input
type="checkbox"
:data-testid="'checkbox-' + inputId"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
@click.stop="$emit('click')"
/>`
}
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
return render(MigrationPicker, {
props: { sourcePath, migrationItemIds },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: {
props: ['severity'],
template: '<div data-testid="error-msg"><slot /></div>'
}
}
}
})
}
describe('MigrationPicker', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('isValidSource', () => {
it('hides migration options when source path is empty', () => {
renderPicker('')
expect(screen.queryByText('Select items to migrate')).toBeNull()
})
it('shows migration options when source path is valid', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const { rerender } = renderPicker('')
await rerender({ sourcePath: '/valid/path' })
await waitFor(() => {
expect(screen.getByText('Select items to migrate')).toBeDefined()
})
})
it('shows optional message when no valid source', () => {
renderPicker('')
expect(screen.getByText('Migration is optional')).toBeDefined()
})
})
describe('validateSource', () => {
it('clears error when source path becomes empty', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
await user.clear(screen.getByTestId('source-input'))
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
it('shows error message when validation fails', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Path not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
})
it('shows no error when validation passes', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/valid/path')
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
})
describe('migrationItemIds watchEffect', () => {
it('emits all item IDs by default (all items start selected)', async () => {
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
migrationItemIds: [],
'onUpdate:migrationItemIds': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button />' },
Message: { template: '<div />' }
}
}
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
const emittedIds = onUpdate.mock.calls[0][0]
expect(Array.isArray(emittedIds)).toBe(true)
expect(emittedIds.length).toBeGreaterThan(0)
})
})
})
describe('browse path', () => {
it('opens directory picker on browse click', async () => {
mockShowDirectoryPicker.mockResolvedValue(null)
renderPicker()
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
})
it('updates source path when directory is selected', async () => {
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
'onUpdate:sourcePath': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: { template: '<div />' }
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
})
})
})
})

View File

@@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import StatusTag from '@/components/maintenance/StatusTag.vue'
const TagStub = defineComponent({
name: 'Tag',
props: {
icon: String,
severity: String,
value: String
},
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
})
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
return render(StatusTag, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: { Tag: TagStub }
}
})
}
describe('StatusTag', () => {
describe('refreshing state', () => {
it('shows info severity when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
})
it('shows refreshing translation key when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
it('shows question icon when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
})
})
describe('error state', () => {
it('shows danger severity when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
})
it('shows error translation key when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
})
})
describe('OK state', () => {
it('shows success severity when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
})
it('shows OK translation key when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
})
})
describe('precedence', () => {
it('shows refreshing state when both refreshing and error are true', () => {
renderStatusTag({ error: true, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
})
})

View File

@@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskCard from '@/components/maintenance/TaskCard.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
shortDescription: 'Short description',
errorDescription: 'Error occurred',
execute: vi.fn().mockResolvedValue(true)
}
const cardStubs = {
Card: {
template: '<div data-testid="card"><slot name="content"></slot></div>'
},
Button: { template: '<button />' }
}
function renderCard(
state: 'OK' | 'error' | 'warning' | 'skipped',
task: MaintenanceTask = baseTask
) {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskCard, {
props: { task },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: cardStubs
}
})
}
describe('TaskCard', () => {
describe('description computed', () => {
it('shows errorDescription when task state is error', () => {
renderCard('error')
expect(screen.getByText('Error occurred')).toBeDefined()
})
it('shows shortDescription when task state is OK', () => {
renderCard('OK')
expect(screen.getByText('Short description')).toBeDefined()
})
it('shows shortDescription when task state is warning', () => {
renderCard('warning')
expect(screen.getByText('Short description')).toBeDefined()
})
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
const taskWithoutErrorDesc: MaintenanceTask = {
...baseTask,
errorDescription: undefined
}
renderCard('error', taskWithoutErrorDesc)
expect(screen.getByText('Short description')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskListItem from '@/components/maintenance/TaskListItem.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
button: { text: 'Fix', icon: 'pi pi-check' },
execute: vi.fn().mockResolvedValue(true)
}
const ButtonStub = {
props: ['severity', 'label', 'icon', 'loading'],
template:
'<button :data-severity="severity" :data-label="label" :data-testid="label ? \'action-button\' : \'icon-button\'" />'
}
function renderItem(state: 'OK' | 'error' | 'warning' | 'skipped') {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskListItem, {
props: { task: baseTask },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
Button: ButtonStub,
Popover: { template: '<div />' },
TaskListStatusIcon: { template: '<span />' }
}
}
})
}
describe('TaskListItem', () => {
describe('severity computed', () => {
it('uses primary severity for error state', () => {
renderItem('error')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses primary severity for warning state', () => {
renderItem('warning')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses secondary severity for OK state', () => {
renderItem('OK')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
it('uses secondary severity for skipped state', () => {
renderItem('skipped')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
})
})

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import TaskListStatusIcon from '@/components/maintenance/TaskListStatusIcon.vue'
type TaskState = 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
function renderIcon(state: TaskState, loading?: boolean) {
return render(TaskListStatusIcon, {
props: { state, loading },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
ProgressSpinner: {
template: '<div data-testid="spinner" />'
}
}
}
})
}
describe('TaskListStatusIcon', () => {
describe('loading / no state', () => {
it('renders spinner when state is undefined', () => {
renderIcon(undefined)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('renders spinner when loading is true', () => {
renderIcon('OK', true)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('hides spinner when state is defined and not loading', () => {
renderIcon('OK', false)
expect(screen.queryByTestId('spinner')).toBeNull()
})
})
})

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon } = vi.hoisted(
() => {
const mockTerminal = {
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
hasSelection: vi.fn<[], boolean>(),
resize: vi.fn(),
cols: 80,
rows: 24
}
const MockTerminal = vi.fn(function () {
return mockTerminal
})
const mockFitAddon = {
proposeDimensions: vi.fn().mockReturnValue({ cols: 80, rows: 24 })
}
const MockFitAddon = vi.fn(function () {
return mockFitAddon
})
return { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon }
}
)
vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal }))
vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon }))
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
import { withSetup } from '@/test/withSetup'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
function getKeyHandler(): (event: KeyboardEvent) => boolean {
return mockTerminal.attachCustomKeyEventHandler.mock.calls[0][0]
}
describe('useTerminal key event handler', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTerminal.hasSelection.mockReturnValue(false)
const element = ref<HTMLElement | undefined>(undefined)
withSetup(() => useTerminal(element))
})
it('allows browser to handle copy when text is selected (Ctrl+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle copy when text is selected (Meta+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not pass copy to browser when no text is selected', () => {
mockTerminal.hasSelection.mockReturnValue(false)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('allows browser to handle paste (Ctrl+V)', () => {
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle paste (Meta+V)', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not intercept non-keydown events', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keyup',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('passes through unrelated key combinations', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: false,
key: 'Enter'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
})

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSerialize, MockSerializeAddon } = vi.hoisted(() => {
const mockSerialize = vi.fn<[], string>()
const MockSerializeAddon = vi.fn(function () {
return { serialize: mockSerialize }
})
return { mockSerialize, MockSerializeAddon }
})
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn(function () {
return { loadAddon: vi.fn(), dispose: vi.fn(), write: vi.fn() }
})
}))
vi.mock('@xterm/addon-serialize', () => ({
SerializeAddon: MockSerializeAddon
}))
import type { Terminal } from '@xterm/xterm'
import { withSetup } from '@/test/withSetup'
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
describe('useTerminalBuffer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSerialize.mockReturnValue('')
})
describe('copyTo', () => {
it('writes serialized buffer content to the destination terminal', () => {
mockSerialize.mockReturnValue('hello world')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('hello world')
})
it('writes empty string when buffer is empty', () => {
mockSerialize.mockReturnValue('')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('')
})
})
})

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { DESKTOP_DIALOGS, getDialog } from '@/constants/desktopDialogs'
describe('getDialog', () => {
it('returns the matching dialog for a valid ID', () => {
const result = getDialog('reinstallVenv')
expect(result.id).toBe('reinstallVenv')
expect(result.title).toBe(DESKTOP_DIALOGS.reinstallVenv.title)
expect(result.message).toBe(DESKTOP_DIALOGS.reinstallVenv.message)
})
it('returns invalidDialog for an unknown string ID', () => {
const result = getDialog('unknownDialog')
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog when given an array of strings', () => {
const result = getDialog(['reinstallVenv', 'other'])
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog for empty string', () => {
const result = getDialog('')
expect(result.id).toBe('invalidDialog')
})
it('returns a deep clone — mutations do not affect the original', () => {
const result = getDialog('reinstallVenv')
const originalFirstLabel = DESKTOP_DIALOGS.reinstallVenv.buttons[0].label
result.buttons[0].label = 'Mutated'
expect(DESKTOP_DIALOGS.reinstallVenv.buttons[0].label).toBe(
originalFirstLabel
)
})
it('every button has a returnValue', () => {
for (const id of Object.keys(DESKTOP_DIALOGS)) {
const result = getDialog(id)
for (const button of result.buttons) {
expect(button.returnValue).toBeDefined()
}
}
})
it('invalidDialog has a close/cancel button', () => {
const result = getDialog('invalidDialog')
expect(result.buttons.some((b) => b.action === 'cancel')).toBe(true)
})
})

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
setBasePath: vi.fn(),
reinstall: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
uv: {
installRequirements: vi.fn<[], Promise<void>>(),
clearCache: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
resetVenv: vi.fn<[], Promise<void>>().mockResolvedValue(undefined)
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
function findTask(id: string) {
const task = DESKTOP_MAINTENANCE_TASKS.find((t) => t.id === id)
if (!task) throw new Error(`Task not found: ${id}`)
return task
}
describe('desktopMaintenanceTasks', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(window, 'open').mockReturnValue(null)
mockElectron.reinstall.mockResolvedValue(undefined)
mockElectron.uv.clearCache.mockResolvedValue(undefined)
mockElectron.uv.resetVenv.mockResolvedValue(undefined)
})
describe('pythonPackages', () => {
it('returns true when installation succeeds', async () => {
mockElectron.uv.installRequirements.mockResolvedValue(undefined)
expect(await findTask('pythonPackages').execute()).toBe(true)
})
it('returns false when installation throws', async () => {
mockElectron.uv.installRequirements.mockRejectedValue(
new Error('install failed')
)
expect(await findTask('pythonPackages').execute()).toBe(false)
})
})
describe('URL-opening tasks', () => {
it('git execute opens the git download page', () => {
findTask('git').execute()
expect(window.open).toHaveBeenCalledWith(
'https://git-scm.com/downloads/',
'_blank'
)
})
it('uv execute opens the uv installation page', () => {
findTask('uv').execute()
expect(window.open).toHaveBeenCalledWith(
'https://docs.astral.sh/uv/getting-started/installation/',
'_blank'
)
})
it('vcRedist execute opens the VC++ redistributable download', () => {
findTask('vcRedist').execute()
expect(window.open).toHaveBeenCalledWith(
'https://aka.ms/vs/17/release/vc_redist.x64.exe',
'_blank'
)
})
})
})

View File

@@ -0,0 +1,288 @@
import { createTestingPinia } from '@pinia/testing'
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron, testTasks } = vi.hoisted(() => {
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
const basicTaskExecute = vi.fn().mockResolvedValue(true)
return {
mockElectron: {
Validation: {
validateInstallation: vi.fn()
}
},
testTasks: [
{
id: 'basicTask',
name: 'Basic Task',
execute: basicTaskExecute
},
{
id: 'terminalTask',
name: 'Terminal Task',
execute: terminalTaskExecute,
usesTerminal: true,
isInstallationFix: true
}
]
}
})
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: testTasks
}))
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
type PartialInstallValidation = Partial<InstallValidation> &
Record<string, unknown>
function makeUpdate(
overrides: PartialInstallValidation = {}
): InstallValidation {
return {
inProgress: false,
installState: 'installed',
...overrides
} as InstallValidation
}
function createStore() {
setActivePinia(createTestingPinia({ stubActions: false }))
return useMaintenanceTaskStore()
}
describe('useMaintenanceTaskStore', () => {
let store: ReturnType<typeof useMaintenanceTaskStore>
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
beforeEach(() => {
vi.resetAllMocks()
store = createStore()
})
describe('processUpdate', () => {
it('sets isRefreshing to true during in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.isRefreshing).toBe(true)
})
it('sets isRefreshing to false when update is complete', () => {
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
expect(store.isRefreshing).toBe(false)
})
it('updates runner state for tasks present in the final update', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).state).toBe('error')
})
it('sets task state to warning from update', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
expect(store.getRunner(basicTask).state).toBe('warning')
})
it('marks runners as refreshing when task id is absent from in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.getRunner(basicTask).refreshing).toBe(true)
})
it('marks task as skipped when absent from final update', () => {
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).state).toBe('skipped')
})
it('clears refreshing flag after final update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).refreshing).toBe(false)
})
it('stores lastUpdate and exposes unsafeBasePath', () => {
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
expect(store.unsafeBasePath).toBe(true)
})
it('exposes unsafeBasePathReason from the update', () => {
store.processUpdate(
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
)
expect(store.unsafeBasePathReason).toBe('oneDrive')
})
})
describe('anyErrors', () => {
it('returns true when any task has error state', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.anyErrors).toBe(true)
})
it('returns false when all tasks are OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
expect(store.anyErrors).toBe(false)
})
it('returns false when all tasks are warning', () => {
store.processUpdate(
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
)
expect(store.anyErrors).toBe(false)
})
})
describe('runner state transitions', () => {
it('marks runner as resolved when transitioning from error to OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
})
it('does not mark resolved for warning to OK transition', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
it('clears resolved flag when task returns to error', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('clearResolved', () => {
it('clears resolved flags on all runners', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
store.clearResolved()
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('execute', () => {
it('returns true when task execution succeeds', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
const result = await store.execute(basicTask)
expect(result).toBe(true)
})
it('returns false when task execution fails', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(false)
const result = await store.execute(basicTask)
expect(result).toBe(false)
})
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(true)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).toHaveBeenCalledOnce()
})
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(false)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('sets runner executing to true during task execution', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(basicTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(basicTask)
expect(store.getRunner(basicTask).executing).toBe(true)
resolveTask(true)
await executePromise
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('clears executing flag when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow('fail')
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('sets runner error message when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(
new Error('something broke')
)
await expect(store.execute(basicTask)).rejects.toThrow()
expect(store.getRunner(basicTask).error).toBe('something broke')
})
it('clears runner error on successful execution', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow()
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(store.getRunner(basicTask).error).toBeUndefined()
})
})
describe('isRunningTerminalCommand', () => {
it('returns true while a terminal task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningTerminalCommand).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningTerminalCommand).toBe(false)
})
it('returns false when no terminal tasks are executing', () => {
expect(store.isRunningTerminalCommand).toBe(false)
})
})
describe('isRunningInstallationFix', () => {
it('returns true while an installation-fix task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningInstallationFix).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningInstallationFix).toBe(false)
})
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View File

@@ -0,0 +1,16 @@
import { render } from '@testing-library/vue'
import { defineComponent } from 'vue'
export function withSetup<T>(composable: () => T): T {
let result!: T
render(
defineComponent({
setup() {
result = composable()
return {}
},
template: '<div />'
})
)
return result
}

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
NetWork: {
canAccessUrl: vi.fn<[url: string], Promise<boolean>>()
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
describe('checkMirrorReachable', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns false for an invalid URL without calling canAccessUrl', async () => {
const result = await checkMirrorReachable('not-a-url')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
it('returns false when canAccessUrl returns false', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(false)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(false)
})
it('returns true when URL is valid and canAccessUrl returns true', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(true)
})
it('passes the mirror URL to canAccessUrl', async () => {
const url = 'https://pypi.org/simple/'
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
await checkMirrorReachable(url)
expect(mockElectron.NetWork.canAccessUrl).toHaveBeenCalledWith(url)
})
it('returns false for empty string', async () => {
const result = await checkMirrorReachable('')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isElectron, isNativeWindow } from '@/utils/envUtil'
describe('isElectron', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when window.electronAPI is an object', () => {
vi.stubGlobal('window', { ...window, electronAPI: {} })
expect(isElectron()).toBe(true)
})
it('returns false when window.electronAPI is undefined', () => {
vi.stubGlobal('window', { ...window, electronAPI: undefined })
expect(isElectron()).toBe(false)
})
it('returns false when window.electronAPI is absent', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isElectron()).toBe(false)
})
})
describe('isNativeWindow', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when Electron and windowControlsOverlay.visible is true', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: true }
}
})
expect(isNativeWindow()).toBe(true)
})
it('returns false when not in Electron', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay.visible is false', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: false }
}
})
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay is absent', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: { ...window.navigator, windowControlsOverlay: undefined }
})
expect(isNativeWindow()).toBe(false)
})
})

View File

@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, ref } from 'vue'
import { withSetup } from '@/test/withSetup'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
describe('useMinLoadingDurationRef', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflects false when source is initially false', () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(false)
})
it('reflects true when source is initially true', () => {
const source = ref(true)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(true)
})
it('becomes true immediately when source transitions to true', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('stays true within minDuration after source returns to false', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
expect(result.value).toBe(true)
})
it('becomes false after minDuration has elapsed', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(250)
await nextTick()
expect(result.value).toBe(false)
})
it('remains true while source is true even after minDuration elapses', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
vi.advanceTimersByTime(500)
await nextTick()
expect(result.value).toBe(true)
})
it('works with a computed ref as input', async () => {
const raw = ref(false)
const source = computed(() => raw.value)
const result = withSetup(() => useMinLoadingDurationRef(source))
raw.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('uses 250ms as default minDuration', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(249)
await nextTick()
expect(result.value).toBe(true)
vi.advanceTimersByTime(1)
await nextTick()
expect(result.value).toBe(false)
})
})

View File

@@ -13,7 +13,8 @@
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts",
"vite.config.mts"
"vite.config.mts",
"vitest.config.mts"
],
"references": []
}

View File

@@ -0,0 +1,22 @@
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(projectRoot, 'src'),
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
}
},
test: {
globals: true,
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
setupFiles: ['./src/test/setup.ts']
}
})

View File

@@ -0,0 +1,88 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "Load3D",
"pos": [50, 50],
"size": [400, 650],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
},
{
"name": "mesh_path",
"type": "STRING",
"links": null
},
{
"name": "normal",
"type": "IMAGE",
"links": null
},
{
"name": "camera_info",
"type": "LOAD3D_CAMERA",
"links": null
},
{
"name": "recording_video",
"type": "VIDEO",
"links": null
},
{
"name": "model_3d",
"type": "FILE_3D",
"links": [1]
}
],
"properties": {
"Node name for S&R": "Load3D"
},
"widgets_values": ["", 1024, 1024, "#000000"]
},
{
"id": 2,
"type": "Preview3D",
"pos": [520, 50],
"size": [450, 600],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model_file",
"type": "FILE_3D",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "Preview3D"
},
"widgets_values": []
}
],
"links": [[1, 1, 6, 2, 0, "*"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,66 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model_a.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [500, 100],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model_b.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model_a.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
},
{
"name": "fake_model_b.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -34,7 +34,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
"directory": "checkpoints"
}
]
},

View File

@@ -0,0 +1,141 @@
{
"id": "test-missing-models-in-bypassed-subgraph",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "subgraph-with-missing-model",
"pos": [450, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "input1-id",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [
{
"id": "output1-id",
"name": "MODEL",
"type": "MODEL",
"linkIds": [2],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [2] },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "MODEL"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,197 @@
{
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [627.5973510742188, 423.0972900390625],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [554.8743286132812, 100.95539093017578],
"size": [270, 262],
"flags": { "collapsed": true },
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [685.1265869140625, 439.1734619140625],
"size": [140, 46],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [4]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [58.7671207025881, 137.7124650620126]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -6,7 +6,7 @@
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 500],
"size": [400, 550],
"flags": {},
"order": 0,
"mode": 0,
@@ -27,14 +27,7 @@
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
}
],
"links": [],

View File

@@ -10,7 +10,7 @@ import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { sleep } from '@e2e/fixtures/utils/timing'
import { nextFrame, sleep } from '@e2e/fixtures/utils/timing'
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
@@ -336,9 +336,7 @@ export class ComfyPage {
}
async nextFrame() {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
await nextFrame(this.page)
}
async delay(ms: number) {
@@ -393,6 +391,27 @@ export class ComfyPage {
return this.page.locator('.dom-widget')
}
async expectScreenshot(
locator: Locator,
name: string | string[],
options?: {
animations?: 'disabled' | 'allow'
caret?: 'hide' | 'initial'
mask?: Array<Locator>
maskColor?: string
maxDiffPixelRatio?: number
maxDiffPixels?: number
omitBackground?: boolean
scale?: 'css' | 'device'
stylePath?: string | Array<string>
threshold?: number
timeout?: number
}
): Promise<void> {
await this.nextFrame()
await comfyExpect(locator).toHaveScreenshot(name, options)
}
async setFocusMode(focusMode: boolean) {
await this.page.evaluate((focusMode) => {
;(window.app!.extensionManager as WorkspaceStore).focusMode = focusMode

View File

@@ -139,6 +139,27 @@ export class Topbar {
await this.menuLocator.waitFor({ state: 'hidden' })
}
/**
* Set Nodes 2.0 on or off via the Comfy logo menu switch (no-op if already
* in the requested state).
*/
async setVueNodesEnabled(enabled: boolean) {
await this.openTopbarMenu()
const nodes2Switch = this.page.getByRole('switch', { name: 'Nodes 2.0' })
await nodes2Switch.waitFor({ state: 'visible' })
if ((await nodes2Switch.isChecked()) !== enabled) {
await nodes2Switch.click()
await this.page.waitForFunction(
(wantEnabled) =>
window.app!.ui.settings.getSettingValue('Comfy.VueNodes.Enabled') ===
wantEnabled,
enabled,
{ timeout: 5000 }
)
}
await this.closeTopbarMenu()
}
/**
* Navigate to a submenu by hovering over a menu item
*/

View File

@@ -10,6 +10,7 @@ export const DefaultGraphPositions = {
textEncodeNode2: { x: 622, y: 400 },
textEncodeNodeToggler: { x: 430, y: 171 },
emptySpaceClick: { x: 35, y: 31 },
emptyCanvasClick: { x: 50, y: 500 },
// Slot positions
clipTextEncodeNode1InputSlot: { x: 427, y: 198 },
@@ -39,6 +40,7 @@ export const DefaultGraphPositions = {
textEncodeNode2: Position
textEncodeNodeToggler: Position
emptySpaceClick: Position
emptyCanvasClick: Position
clipTextEncodeNode1InputSlot: Position
clipTextEncodeNode2InputSlot: Position
clipTextEncodeNode2InputLinkPath: Position

View File

@@ -17,8 +17,17 @@ export class AppModeHelper {
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
/** The "Switch to Outputs" button inside the connect-output popover. */
public readonly connectOutputSwitchButton: Locator
/** The empty-workflow dialog shown when entering builder on an empty graph. */
public readonly emptyWorkflowDialog: Locator
/** "Back to workflow" button on the empty-workflow dialog. */
public readonly emptyWorkflowBackButton: Locator
/** "Load template" button on the empty-workflow dialog. */
public readonly emptyWorkflowLoadTemplateButton: Locator
/** The empty-state placeholder shown when no outputs are selected. */
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
@@ -39,6 +48,18 @@ export class AppModeHelper {
public readonly loadTemplateButton: Locator
/** The cancel button for an in-progress run in the output history. */
public readonly cancelRunButton: Locator
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
public readonly arrangePreview: Locator
/** Arrange-step state shown when no outputs have been configured. */
public readonly arrangeNoOutputs: Locator
/** "Switch to Outputs" button inside the arrange no-outputs state. */
public readonly arrangeSwitchToOutputsButton: Locator
/** The Vue Node switch notification popup shown on entering builder. */
public readonly vueNodeSwitchPopup: Locator
/** The "Dismiss" button inside the Vue Node switch popup. */
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
@@ -47,9 +68,22 @@ export class AppModeHelper {
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
)
this.connectOutputSwitchButton = this.page.getByTestId(
TestIds.builder.connectOutputSwitch
)
this.emptyWorkflowDialog = this.page.getByTestId(
TestIds.builder.emptyWorkflowDialog
)
this.emptyWorkflowBackButton = this.page.getByTestId(
TestIds.builder.emptyWorkflowBack
)
this.emptyWorkflowLoadTemplateButton = this.page.getByTestId(
TestIds.builder.emptyWorkflowLoadTemplate
)
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
@@ -75,6 +109,22 @@ export class AppModeHelper {
this.cancelRunButton = this.page.getByTestId(
TestIds.outputHistory.cancelRun
)
this.arrangePreview = this.page.getByTestId(TestIds.appMode.arrangePreview)
this.arrangeNoOutputs = this.page.getByTestId(
TestIds.appMode.arrangeNoOutputs
)
this.arrangeSwitchToOutputsButton = this.page.getByTestId(
TestIds.appMode.arrangeSwitchToOutputs
)
this.vueNodeSwitchPopup = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchPopup
)
this.vueNodeSwitchDismissButton = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDismiss
)
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
}
private get page(): Page {
@@ -92,8 +142,33 @@ export class AppModeHelper {
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
}
/** Set preference so the Vue node switch popup does not appear in builder. */
async suppressVueNodeSwitchPopup() {
await this.comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
}
/** Allow the Vue node switch popup so tests can assert its behavior. */
async allowVueNodeSwitchPopup() {
await this.comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
false
)
}
/** Enter builder mode via the "Workflow actions" dropdown. */
async enterBuilder() {
// Wait for any workflow-tab popover to dismiss before clicking —
// the popover overlay can intercept the "Workflow actions" click.
// Best-effort: the popover may or may not exist; if it stays visible
// past the timeout we still proceed with the click.
await this.page
.locator('.workflow-popover-fade')
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
@@ -108,7 +183,6 @@ export class AppModeHelper {
async toggleAppMode() {
await this.comfyPage.workflow.waitForActiveWorkflow()
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
await this.comfyPage.nextFrame()
}
/**

View File

@@ -13,18 +13,30 @@ export class BuilderStepsHelper {
return this.comfyPage.page
}
get inputsButton(): Locator {
return this.toolbar.getByRole('button', { name: 'Inputs' })
}
get outputsButton(): Locator {
return this.toolbar.getByRole('button', { name: 'Outputs' })
}
get previewButton(): Locator {
return this.toolbar.getByRole('button', { name: 'Preview' })
}
async goToInputs() {
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
await this.inputsButton.click()
await this.comfyPage.nextFrame()
}
async goToOutputs() {
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
await this.outputsButton.click()
await this.comfyPage.nextFrame()
}
async goToPreview() {
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
await this.previewButton.click()
await this.comfyPage.nextFrame()
}
}

View File

@@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position } from '@e2e/fixtures/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CanvasHelper {
constructor(
@@ -10,18 +11,12 @@ export class CanvasHelper {
private resetViewButton: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async resetView(): Promise<void> {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
await this.page.mouse.move(10, 10)
await this.nextFrame()
await nextFrame(this.page)
}
async zoom(deltaY: number, steps: number = 1): Promise<void> {
@@ -29,7 +24,7 @@ export class CanvasHelper {
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await this.nextFrame()
await nextFrame(this.page)
}
async pan(offset: Position, safeSpot?: Position): Promise<void> {
@@ -38,7 +33,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
await nextFrame(this.page)
}
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
@@ -56,22 +51,22 @@ export class CanvasHelper {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
await nextFrame(this.page)
}
async rightClick(x: number = 10, y: number = 10): Promise<void> {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
await nextFrame(this.page)
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
await nextFrame(this.page)
}
async click(position: Position): Promise<void> {
await this.canvas.click({ position })
await this.nextFrame()
await nextFrame(this.page)
}
/**
@@ -107,7 +102,7 @@ export class CanvasHelper {
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
await nextFrame(this.page)
}
/**
@@ -116,12 +111,12 @@ export class CanvasHelper {
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
await nextFrame(this.page)
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()
await nextFrame(this.page)
}
async dragAndDrop(source: Position, target: Position): Promise<void> {
@@ -129,7 +124,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await this.nextFrame()
await nextFrame(this.page)
}
async moveMouseToEmptyArea(): Promise<void> {
@@ -152,7 +147,7 @@ export class CanvasHelper {
await this.page.evaluate((s) => {
window.app!.canvas.ds.scale = s
}, scale)
await this.nextFrame()
await nextFrame(this.page)
}
async convertOffsetToCanvas(
@@ -236,12 +231,12 @@ export class CanvasHelper {
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
await nextFrame(this.page)
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
await nextFrame(this.page)
}
await this.page.mouse.up({ button: 'middle' })

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import type { KeyCombo } from '@/platform/keybindings/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CommandHelper {
constructor(private readonly page: Page) {}
@@ -20,6 +21,7 @@ export class CommandHelper {
},
{ commandId, metadata }
)
await nextFrame(this.page)
}
async registerCommand(

View File

@@ -5,18 +5,11 @@ import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class DragDropHelper {
constructor(private readonly page: Page) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
}
async dragAndDropExternalResource(
options: {
fileName?: string
@@ -145,7 +138,7 @@ export class DragDropHelper {
await uploadResponsePromise
}
await this.nextFrame()
await nextFrame(this.page)
}
async dragAndDropFile(

View File

@@ -1,13 +1,21 @@
import type { Locator, Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class KeyboardHelper {
constructor(
private readonly page: Page,
private readonly canvas: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
async press(key: string, locator?: Locator | null): Promise<void> {
const target = locator ?? this.canvas
await target.press(key)
await nextFrame(this.page)
}
async delete(locator?: Locator | null): Promise<void> {
await this.press('Delete', locator)
}
async ctrlSend(
@@ -16,7 +24,7 @@ export class KeyboardHelper {
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
await nextFrame(this.page)
}
async selectAll(locator?: Locator | null): Promise<void> {

View File

@@ -140,13 +140,11 @@ export class NodeOperationsHelper {
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
target
)
await this.comfyPage.nextFrame()
if (revertAfter) {
await this.comfyPage.canvasOps.dragAndDrop(
{ x: target.x - 2, y: target.y - 1 },
bottomRight
)
await this.comfyPage.nextFrame()
}
}
@@ -158,7 +156,6 @@ export class NodeOperationsHelper {
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
}
async fillPromptDialog(value: string): Promise<void> {
@@ -192,7 +189,6 @@ export class NodeOperationsHelper {
y: 300
}
)
await this.comfyPage.nextFrame()
}
async adjustEmptyLatentWidth(): Promise<void> {

View File

@@ -0,0 +1,283 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'
import {
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS,
PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS,
preview3dCameraStatesDiffer as cameraStatesDiffer,
preview3dRestoreCameraStatesMatch
} from '@e2e/fixtures/utils/preview3dCameraState'
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
async function orbitDragFromCanvasCenter(
page: Page,
canvas: Locator,
delta: { dx: number; dy: number },
steps: number
): Promise<void> {
await canvas.scrollIntoViewIfNeeded()
await expect
.poll(
async () => {
const b = await canvas.boundingBox()
return b !== null && b.width > 0 && b.height > 0
},
{
timeout: 15_000,
message:
'3D canvas should have non-zero bounding box before orbit drag (layout / WebGL surface ready)'
}
)
.toBe(true)
const box = await canvas.boundingBox()
expect(box, 'canvas bounding box should exist').not.toBeNull()
const cx = box!.x + box!.width / 2
const cy = box!.y + box!.height / 2
await page.mouse.move(cx, cy)
await page.mouse.down()
await page.mouse.move(cx + delta.dx, cy + delta.dy, { steps })
await page.mouse.up()
}
export class Preview3DPipelineContext {
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
static readonly loadNodeId = '1'
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
static readonly previewNodeId = '2'
readonly load3d: Load3DHelper
readonly preview3d: Load3DHelper
constructor(readonly comfyPage: ComfyPage) {
this.load3d = new Load3DHelper(
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.loadNodeId)
)
this.preview3d = new Load3DHelper(
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.previewNodeId)
)
}
async getModelFileWidgetValue(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(Number(id))
if (!node?.widgets) return ''
const w = node.widgets.find((x) => x.name === 'model_file')
const v = w?.value
return typeof v === 'string' ? v : ''
}, nodeId)
}
async getLastTimeModelFile(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(Number(id))
if (!node?.properties) return ''
const v = (node.properties as Record<string, unknown>)[
'Last Time Model File'
]
return typeof v === 'string' ? v : ''
}, nodeId)
}
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(Number(id))
if (!node?.properties) return null
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
if (cfg === null || typeof cfg !== 'object') return null
if (!('state' in cfg)) return null
const rec = cfg as Record<string, unknown>
return rec.state ?? null
}, nodeId)
}
async seedLoad3dWithCubeObj(): Promise<void> {
const fileChooserPromise = this.comfyPage.page.waitForEvent('filechooser')
await this.load3d.getUploadButton('upload 3d model').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(assetPath('cube.obj'))
await expect
.poll(() =>
this.getModelFileWidgetValue(Preview3DPipelineContext.loadNodeId)
)
.toContain('cube.obj')
await this.load3d.waitForModelLoaded()
await this.comfyPage.nextFrame()
}
async setNonDefaultLoad3dCameraState(): Promise<void> {
const initialCamera = await this.getCameraStateFromProperties(
Preview3DPipelineContext.loadNodeId
)
await orbitDragFromCanvasCenter(
this.comfyPage.page,
this.load3d.canvas,
{ dx: 80, dy: 20 },
10
)
await this.comfyPage.nextFrame()
await expect
.poll(
async () => {
const current = await this.getCameraStateFromProperties(
Preview3DPipelineContext.loadNodeId
)
if (current === null) return false
if (initialCamera === null) return true
return cameraStatesDiffer(current, initialCamera, 1e-4)
},
{
timeout: 10_000,
message:
'Load3D camera state should change after orbit drag (see cameraStatesDiffer)'
}
)
.toBe(true)
}
async nudgePreview3dCameraIntoProperties(): Promise<void> {
await orbitDragFromCanvasCenter(
this.comfyPage.page,
this.preview3d.canvas,
{ dx: -60, dy: 20 },
10
)
await this.comfyPage.nextFrame()
}
async alignPreview3dWorkflowUiSettings(): Promise<void> {
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await this.comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
}
async queuePromptAndWaitIdle(timeoutMs: number): Promise<void> {
await this.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await this.comfyPage.workflow.waitForWorkflowIdle(timeoutMs)
}
async assertPreview3dExecutionOutputSettled(): Promise<void> {
const previewId = Preview3DPipelineContext.previewNodeId
await expect
.poll(() => this.getModelFileWidgetValue(previewId))
.not.toBe('')
const modelPath = await this.getModelFileWidgetValue(previewId)
expect(modelPath.length, 'Preview3D model path populated').toBeGreaterThan(
4
)
await expect
.poll(() => this.getLastTimeModelFile(previewId))
.toBe(modelPath)
await this.preview3d.waitForModelLoaded()
}
async assertPreview3dCanvasNonEmpty(): Promise<void> {
await expect
.poll(async () => {
const b = await this.preview3d.canvas.boundingBox()
return (b?.width ?? 0) > 0 && (b?.height ?? 0) > 0
})
.toBe(true)
}
async getPreview3dCameraStateWhenReady(): Promise<unknown> {
let last: unknown = null
await expect
.poll(
async () => {
last = await this.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
)
return last !== null
},
{
message:
'Preview3D Camera Config.state should exist after orbit (cameraChanged)'
}
)
.toBe(true)
return last
}
async saveNamedWorkflowToSidebar(prefix: string): Promise<string> {
const workflowName = `${prefix}-${Date.now().toString(36)}`
await this.comfyPage.menu.workflowsTab.open()
await this.comfyPage.menu.topbar.saveWorkflow(workflowName)
return workflowName
}
async reloadPageAndWaitForAppShell(): Promise<void> {
await this.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await this.comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager,
{ timeout: 30_000 }
)
await this.comfyPage.page.locator('.p-blockui-mask').waitFor({
state: 'hidden',
timeout: 30_000
})
await this.comfyPage.nextFrame()
}
async openPersistedWorkflowFromSidebar(workflowName: string): Promise<void> {
await this.alignPreview3dWorkflowUiSettings()
const tab = this.comfyPage.menu.workflowsTab
await tab.open()
await tab.getPersistedItem(workflowName).click()
await this.comfyPage.workflow.waitForWorkflowIdle(30_000)
await this.comfyPage.vueNodes.waitForNodes()
}
async assertPreview3dModelPathAndLastTime(path: string): Promise<void> {
const previewId = Preview3DPipelineContext.previewNodeId
await expect.poll(() => this.getModelFileWidgetValue(previewId)).toBe(path)
await expect.poll(() => this.getLastTimeModelFile(previewId)).toBe(path)
await this.preview3d.waitForModelLoaded()
}
async assertPreview3dCameraRestored(savedCamera: unknown): Promise<void> {
await expect
.poll(
async () =>
preview3dRestoreCameraStatesMatch(
await this.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
),
savedCamera
),
{
timeout: 15_000,
message: `Preview3D camera after reload should match saved state (axis max delta ≤ ${PREVIEW3D_CAMERA_AXIS_RESTORE_EPS}, zoom delta ≤ ${PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS}; see browser_tests/fixtures/utils/preview3dCameraState.ts)`
}
)
.toBe(true)
}
}
export const preview3dPipelineTest = comfyPageFixture.extend<{
preview3dPipeline: Preview3DPipelineContext
}>({
preview3dPipeline: async ({ comfyPage }, use) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.workflow.loadWorkflow('3d/preview3d_pipeline')
await comfyPage.vueNodes.waitForNodes()
const pipeline = new Preview3DPipelineContext(comfyPage)
await use(pipeline)
await comfyPage.workflow.setupWorkflowsDirectory({})
}
})

View File

@@ -1,5 +1,7 @@
import type { Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class SettingsHelper {
constructor(private readonly page: Page) {}
@@ -10,6 +12,7 @@ export class SettingsHelper {
},
{ id: settingId, value: settingValue }
)
await nextFrame(this.page)
}
async getSetting<T = unknown>(settingId: string): Promise<T> {

View File

@@ -465,11 +465,7 @@ export class SubgraphHelper {
const serialized = await this.page.evaluate(() =>
window.app!.graph!.serialize()
)
await this.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized as ComfyWorkflowJSON
)
await this.comfyPage.nextFrame()
await this.comfyPage.workflow.loadGraphData(serialized as ComfyWorkflowJSON)
}
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
@@ -477,14 +473,12 @@ export class SubgraphHelper {
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await this.comfyPage.nextFrame()
return subgraphNode
}
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,

View File

@@ -70,10 +70,19 @@ export class WorkflowHelper {
)
}
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
await this.comfyPage.page.evaluate(
(wf) => window.app!.loadGraphData(wf),
workflow
)
await this.comfyPage.nextFrame()
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)
)
await this.waitForWorkflowIdle()
await this.comfyPage.nextFrame()
if (test.info().tags.includes('@vue-nodes')) {
await this.comfyPage.vueNodes.waitForNodes()

View File

@@ -137,7 +137,11 @@ export const TestIds = {
widgetItem: 'builder-widget-item',
widgetLabel: 'builder-widget-label',
outputPlaceholder: 'builder-output-placeholder',
connectOutputPopover: 'builder-connect-output-popover'
connectOutputPopover: 'builder-connect-output-popover',
connectOutputSwitch: 'builder-connect-output-switch',
emptyWorkflowDialog: 'builder-empty-workflow-dialog',
emptyWorkflowBack: 'builder-empty-workflow-back',
emptyWorkflowLoadTemplate: 'builder-empty-workflow-load-template'
},
outputHistory: {
outputs: 'linear-outputs',
@@ -163,7 +167,13 @@ export const TestIds = {
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
loadTemplate: 'linear-welcome-load-template'
loadTemplate: 'linear-welcome-load-template',
arrangePreview: 'linear-arrange-preview',
arrangeNoOutputs: 'linear-arrange-no-outputs',
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
vueNodeSwitchPopup: 'linear-vue-node-switch-popup',
vueNodeSwitchDismiss: 'linear-vue-node-switch-dismiss',
vueNodeSwitchDontShowAgain: 'linear-vue-node-switch-dont-show-again'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
@@ -188,6 +198,16 @@ export const TestIds = {
},
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
batchNav: 'batch-nav',
beforeBatch: 'before-batch',
afterBatch: 'after-batch',
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
}
} as const
@@ -221,3 +241,4 @@ export type TestIdValue =
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]

View File

@@ -388,7 +388,6 @@ export class NodeReference {
async copy() {
await this.click('title')
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async delete(): Promise<void> {
await this.click('title')
@@ -434,7 +433,6 @@ export class NodeReference {
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)

View File

@@ -0,0 +1,87 @@
interface Preview3dCameraStatePayload {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
zoom?: number
cameraType?: string
}
type Vec3 = { x: number; y: number; z: number }
function isVec3(v: unknown): v is Vec3 {
if (v === null || typeof v !== 'object') return false
const r = v as Record<string, unknown>
return (
'x' in v &&
typeof r.x === 'number' &&
'y' in v &&
typeof r.y === 'number' &&
'z' in v &&
typeof r.z === 'number'
)
}
function isPreview3dCameraStatePayload(
v: unknown
): v is Preview3dCameraStatePayload {
if (v === null || typeof v !== 'object') return false
if (!('position' in v) || !('target' in v)) return false
const r = v as Record<string, unknown>
return isVec3(r.position) && isVec3(r.target)
}
function vecMaxAbsDelta(a: Vec3, b: Vec3): number {
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z))
}
function vecWithinEps(a: Vec3, b: Vec3, eps: number): boolean {
return vecMaxAbsDelta(a, b) <= eps
}
/**
* Max abs error per position/target axis when comparing restored Preview3D
* camera state (same order of magnitude as the former 2e-2 poll tolerance).
*/
export const PREVIEW3D_CAMERA_AXIS_RESTORE_EPS = 0.02
/**
* Max abs zoom error when comparing restored Preview3D state (aligned with
* Playwright `toBeCloseTo(..., 5)`-style checks on typical zoom magnitudes).
*/
export const PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS = 1e-4
export function preview3dRestoreCameraStatesMatch(
a: unknown,
b: unknown
): boolean {
if (!isPreview3dCameraStatePayload(a) || !isPreview3dCameraStatePayload(b)) {
return false
}
if (a.cameraType !== b.cameraType) return false
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
if (Math.abs(zoomA - zoomB) > PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS) {
return false
}
return (
vecWithinEps(a.position, b.position, PREVIEW3D_CAMERA_AXIS_RESTORE_EPS) &&
vecWithinEps(a.target, b.target, PREVIEW3D_CAMERA_AXIS_RESTORE_EPS)
)
}
export function preview3dCameraStatesDiffer(
a: unknown,
b: unknown,
eps: number
): boolean {
if (!isPreview3dCameraStatePayload(a) || !isPreview3dCameraStatePayload(b)) {
return true
}
if (a.cameraType !== b.cameraType) return true
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
if (Math.abs(zoomA - zoomB) > eps) return true
return !(
vecWithinEps(a.position, b.position, eps) &&
vecWithinEps(a.target, b.target, eps)
)
}

View File

@@ -1,3 +1,9 @@
import type { Page } from '@playwright/test'
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function nextFrame(page: Page): Promise<number> {
return page.evaluate(() => new Promise<number>(requestAnimationFrame))
}

View File

@@ -35,6 +35,13 @@ export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
}
export async function triggerSerialization(page: Page): Promise<void> {
await page.waitForFunction(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
const widget = node?.widgets?.find((w) => w.name === 'mask')
return typeof widget?.serializeValue === 'function'
})
await page.evaluate(async () => {
const graph = window.graph as TestGraphAccess | undefined
if (!graph) {
@@ -50,17 +57,22 @@ export async function triggerSerialization(page: Page): Promise<void> {
)
}
const widget = node.widgets?.find((w) => w.name === 'mask')
if (!widget) {
const widgetIndex = node.widgets?.findIndex((w) => w.name === 'mask') ?? -1
if (widgetIndex === -1) {
throw new Error('Widget "mask" not found on target node 1.')
}
const widget = node.widgets?.[widgetIndex]
if (!widget) {
throw new Error(`Widget index ${widgetIndex} not found on target node 1.`)
}
if (typeof widget.serializeValue !== 'function') {
throw new Error(
'mask widget on node 1 does not have a serializeValue function.'
)
}
await widget.serializeValue(node, 0)
await widget.serializeValue(node, widgetIndex)
})
}

View File

@@ -54,7 +54,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.checkState()
).workflow.activeWorkflow?.changeTracker.captureCanvasState()
}, value)
}

View File

@@ -0,0 +1,70 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
test.describe('App mode arrange step', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.appMode.suppressVueNodeSwitchPopup()
})
test('Placeholder is shown when outputs are configured but no run has happened', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage)
await appMode.steps.goToPreview()
await expect(appMode.steps.previewButton).toHaveAttribute(
'aria-current',
'step'
)
await expect(appMode.arrangePreview).toBeVisible()
await expect(appMode.arrangeNoOutputs).toBeHidden()
})
test('No-outputs state navigates to the Outputs step via "Switch to Outputs"', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await appMode.enterBuilder()
await appMode.steps.goToPreview()
await expect(appMode.arrangeNoOutputs).toBeVisible()
await expect(appMode.arrangePreview).toBeHidden()
await appMode.arrangeSwitchToOutputsButton.click()
await expect(appMode.steps.outputsButton).toHaveAttribute(
'aria-current',
'step'
)
await expect(appMode.arrangeNoOutputs).toBeHidden()
})
test('Connect-output popover from preview step navigates to the Outputs step', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await appMode.enterBuilder()
// From a non-select step (preview/arrange), the popover surfaces a
// "Switch to Outputs" shortcut alongside cancel.
await appMode.steps.goToPreview()
await appMode.footer.saveAsButton.click()
await expect(appMode.connectOutputPopover).toBeVisible()
await expect(appMode.connectOutputSwitchButton).toBeVisible()
await appMode.connectOutputSwitchButton.click()
await expect(appMode.connectOutputPopover).toBeHidden()
await expect(appMode.steps.outputsButton).toHaveAttribute(
'aria-current',
'step'
)
})
})

View File

@@ -0,0 +1,84 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
async function enterBuilderExpectVueNodeSwitchPopup(comfyPage: ComfyPage) {
const { appMode } = comfyPage
await appMode.enterBuilder()
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
}
async function expectVueNodesEnabled(comfyPage: ComfyPage) {
await expect
.poll(() =>
comfyPage.settings.getSetting<boolean>('Comfy.VueNodes.Enabled')
)
.toBe(true)
}
test.describe('Vue node switch notification popup', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.appMode.allowVueNodeSwitchPopup()
})
test('Popup appears when entering builder; dismiss closes without persisting and shows again on a later entry', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
await appMode.vueNodeSwitchDismissButton.click()
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
// "Don't show again" was not checked → preference remains false
await expect
.poll(() =>
comfyPage.settings.getSetting<boolean>(
'Comfy.AppBuilder.VueNodeSwitchDismissed'
)
)
.toBe(false)
// Disable vue nodes and re-enter builder
await appMode.footer.exitBuilder()
await comfyPage.menu.topbar.setVueNodesEnabled(false)
await appMode.enterBuilder()
await expectVueNodesEnabled(comfyPage)
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
})
test('"Don\'t show again" persists the dismissal and suppresses future popups', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
await expectVueNodesEnabled(comfyPage)
// Dismiss with dont show again checked
await appMode.vueNodeSwitchDontShowAgainCheckbox.check()
await appMode.vueNodeSwitchDismissButton.click()
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
await expect
.poll(() =>
comfyPage.settings.getSetting<boolean>(
'Comfy.AppBuilder.VueNodeSwitchDismissed'
)
)
.toBe(true)
// Disable vue nodes and re-enter builder
await appMode.footer.exitBuilder()
await comfyPage.menu.topbar.setVueNodesEnabled(false)
await appMode.enterBuilder()
await expectVueNodesEnabled(comfyPage)
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
})
})

View File

@@ -6,6 +6,7 @@ import {
test.describe('App mode welcome states', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.appMode.suppressVueNodeSwitchPopup()
})
test('Empty workflow text is visible when no nodes', async ({
@@ -58,4 +59,37 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('Empty workflow dialog blocks entering builder on an empty graph', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await comfyPage.nodeOps.clearGraph()
await appMode.enterBuilder()
await expect(appMode.emptyWorkflowDialog).toBeVisible()
await expect(appMode.emptyWorkflowBackButton).toBeVisible()
await expect(appMode.emptyWorkflowLoadTemplateButton).toBeVisible()
// Back to workflow dismisses the dialog and returns to graph mode
await appMode.emptyWorkflowBackButton.click()
await expect(appMode.emptyWorkflowDialog).toBeHidden()
await expect(comfyPage.canvas).toBeVisible()
})
test('Empty workflow dialog "Load template" opens the template selector', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await comfyPage.nodeOps.clearGraph()
await appMode.enterBuilder()
await expect(appMode.emptyWorkflowDialog).toBeVisible()
await appMode.emptyWorkflowLoadTemplateButton.click()
await expect(appMode.emptyWorkflowDialog).toBeHidden()
await expect(comfyPage.templates.content).toBeVisible()
})
})

View File

@@ -0,0 +1,47 @@
import type { Locator, Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
function tooltipLocator(page: Page): Locator {
return page.locator('[role="tooltip"]')
}
async function hoverAway(page: Page): Promise<void> {
await page.mouse.move(0, 0)
}
test.describe('BaseTooltip regression', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Queue history button shows tooltip on hover', async ({ comfyPage }) => {
const queueButton = comfyPage.page.getByTestId('queue-overlay-toggle')
await queueButton.hover()
const tooltip = tooltipLocator(comfyPage.page)
await expect(tooltip).toBeVisible()
await hoverAway(comfyPage.page)
await expect(tooltip).not.toBeVisible()
})
test('Toggle properties panel button shows tooltip on hover', async ({
comfyPage
}) => {
const panelButton = comfyPage.page
.getByLabel(/Toggle properties panel/i)
.first()
await panelButton.hover()
const tooltip = tooltipLocator(comfyPage.page)
await expect(tooltip).toBeVisible()
await hoverAway(comfyPage.page)
await expect(tooltip).not.toBeVisible()
})
})

View File

@@ -30,7 +30,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
window.app!.graph!.setDirtyCanvas(true, true)
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker?.checkState()
).workflow.activeWorkflow?.changeTracker?.captureCanvasState()
})
await expect
.poll(() => comfyPage.page.title())

View File

@@ -157,7 +157,10 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
test('Reordering inputs in one app does not corrupt another app', async ({
comfyPage
}) => {
}, testInfo) => {
// This test creates 2 apps, switches tabs 3 times, and enters builder 3
// times — the default 15s timeout is insufficient in CI.
testInfo.setTimeout(45_000)
const { appMode } = comfyPage
const app2Widgets = ['seed', 'steps']
const app1Reordered = ['steps', 'cfg', 'seed']

View File

@@ -110,8 +110,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
})

View File

@@ -29,7 +29,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
await comfyPage.nextFrame()
})
test.describe('Trigger button', () => {
@@ -46,7 +45,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
await expect(modeIcon).toHaveClass(mode.iconPattern)
@@ -103,7 +101,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
}) => {
if (!mode.isReadOnly) {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
}
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
@@ -156,7 +153,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -208,7 +204,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -229,8 +224,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts unlocked'
).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyH')
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
@@ -241,13 +235,11 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts locked'
).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyV')
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()

View File

@@ -71,7 +71,7 @@ async function waitForChangeTrackerSettled(
) {
// Visible node flags can flip before undo finishes loadGraphData() and
// updates the tracker. Poll the tracker's own settled state so we do not
// start the next transaction while checkState() is still gated.
// start the next transaction while captureCanvasState() is still gated.
await expect
.poll(() => getChangeTrackerDebugState(comfyPage))
.toMatchObject({
@@ -223,8 +223,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(node).toBePinned()
await afterChange(comfyPage)
}
@@ -273,4 +272,42 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
test('Undo preserves viewport offset', async ({ comfyPage }) => {
// Pan to a distinct offset so we can detect drift
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
const viewportBefore = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
// Make a graph change so we have something to undo
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
// Undo the collapse — viewport should be preserved
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(viewportBefore.scale, 2),
offset: [
expect.closeTo(viewportBefore.offset[0], 0),
expect.closeTo(viewportBefore.offset[1], 0)
]
})
})
})

View File

@@ -12,7 +12,7 @@ test.describe(
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Prevents checkState from corrupting workflow state during tab switch', async ({
test('Prevents captureCanvasState from corrupting workflow state during tab switch', async ({
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
@@ -21,9 +21,9 @@ test.describe(
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
// Register an extension that forces checkState during graph loading.
// Register an extension that forces captureCanvasState during graph loading.
// This simulates the bug scenario where a user clicks during graph loading
// which triggers a checkState call on the wrong graph, corrupting the activeState.
// which triggers a captureCanvasState call on the wrong graph, corrupting the activeState.
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestCheckStateDuringLoad',
@@ -35,7 +35,7 @@ test.describe(
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
// Simulate the user clicking during graph loading
workflow.changeTracker.checkState()
workflow.changeTracker.captureCanvasState()
}
})
})

View File

@@ -74,18 +74,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
return node!.id
})
// Wait for the asset widget to mount AND its value to resolve.
// The widget type becomes 'asset' before the value is populated,
// so poll for both conditions together to avoid a race where the
// type check passes but the value is still the placeholder.
await expect
.poll(
async () => {
return await comfyPage.page.evaluate((id) => {
() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
}, nodeId)
},
{ timeout: 10_000 }
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
{ timeout: 15_000 }
)
.toBe(CLOUD_ASSETS[0].name)
})

View File

@@ -64,3 +64,29 @@ test.describe(
})
}
)
test.describe(
'Collapsed node links inside subgraph on first entry',
{ tag: ['@canvas', '@node', '@vue-nodes', '@subgraph', '@screenshot'] },
() => {
test('renders collapsed node links correctly after fitView on first subgraph entry', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-collapsed-node'
)
await comfyPage.nextFrame()
await comfyPage.vueNodes.enterSubgraph('2')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// fitView runs on first entry and re-syncs slot layouts for the
// pre-collapsed KSampler. Screenshot captures the rendered canvas
// links to guard against regressing the stale-coordinate bug.
await expect(comfyPage.canvas).toHaveScreenshot(
'subgraph-entry-collapsed-node-links.png'
)
})
}
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -157,18 +157,15 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark-all-colors.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-light-red.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
@@ -181,7 +178,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -190,7 +186,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
'Comfy.ColorPalette',
'custom_obsidian_dark'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -212,15 +207,12 @@ test.describe(
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-0.5.png')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
@@ -229,8 +221,8 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'node-opacity-0.2-arc-theme.png'
)
})
@@ -240,7 +232,6 @@ test.describe(
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
@@ -279,7 +270,6 @@ test.describe(
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-lightened-colors.png'
)

View File

@@ -146,7 +146,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyCanvasClick
})
await comfyPage.nextFrame()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
@@ -155,7 +157,6 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
await comfyPage.nextFrame()
const uploadPromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
@@ -175,7 +176,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
// Step 3: Click empty canvas area, paste image → creates new LoadImage
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyCanvasClick
})
await comfyPage.nextFrame()
const uploadPromise2 = comfyPage.page.waitForResponse(

View File

@@ -52,8 +52,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+Equal')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -63,8 +62,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+Minus')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -82,8 +80,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Period')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -93,8 +90,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'h' locks canvas", async ({ comfyPage }) => {
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyH')
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
})
@@ -102,11 +98,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyV')
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
})
@@ -121,16 +115,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
await expect.poll(() => node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+KeyC')
await expect.poll(() => node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+KeyC')
await expect.poll(() => node.isCollapsed()).toBe(false)
})
@@ -140,7 +131,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
@@ -150,13 +140,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect.poll(() => getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+KeyM')
// NEVER (2) = muted
await expect.poll(() => getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+KeyM')
await expect.poll(() => getMode()).toBe(0)
})
})
@@ -239,16 +227,14 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
@@ -265,8 +251,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+o')
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
@@ -288,11 +273,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+Shift+KeyE')
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)

View File

@@ -145,15 +145,27 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may re-render during search
// filtering, causing the first click to land on a stale element.
// Wait for the search filter to fully settle — PrimeVue re-renders
// the entire settings list after typing, and the combobox element is
// replaced during re-render. Wait until the filtered list stabilises
// before interacting with the combobox.
const settingItems = dialog.root.locator('[data-setting-id]')
await expect
.poll(() => settingItems.count(), { timeout: 5000 })
.toBeLessThanOrEqual(5)
const select = settingRow.getByRole('combobox')
await expect(select).toBeVisible()
await expect(select).toBeEnabled()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may still re-render after the
// filter settles, causing the first click to land on a stale element.
await expect(async () => {
const expanded = await select.getAttribute('aria-expanded')
if (expanded !== 'true') await select.click()
await expect(select).toHaveAttribute('aria-expanded', 'true')
}).toPass({ timeout: 5000 })
}).toPass({ timeout: 10_000 })
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'

View File

@@ -214,4 +214,34 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await expect(overlay).toBeHidden()
})
})
test.describe('Count independence from node selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('missing model count stays constant when a node is selected', async ({
comfyPage
}) => {
// Regression: ErrorOverlay previously read the selection-filtered
// missingModelGroups from useErrorGroups, so selecting one of two
// missing-model nodes would shrink the overlay label from
// "2 required models are missing" to "1". The overlay must show
// the workflow total regardless of canvas selection.
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).toContainText(/2 required models are missing/i)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await expect(overlay).toContainText(/2 required models are missing/i)
})
})
})

View File

@@ -55,4 +55,30 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).toBeHidden()
})
test('Focus mode toggle preserves properties panel width', async ({
comfyPage
}) => {
// Open the properties panel
await comfyPage.actionbar.propertiesButton.click()
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
// Record the initial panel width
const initialBox = await comfyPage.menu.propertiesPanel.root.boundingBox()
expect(initialBox).not.toBeNull()
const initialWidth = initialBox!.width
// Toggle focus mode on then off
await comfyPage.setFocusMode(true)
await comfyPage.setFocusMode(false)
// Properties panel should be visible again with the same width
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
await expect
.poll(async () => {
const box = await comfyPage.menu.propertiesPanel.root.boundingBox()
return box ? Math.abs(box.width - initialWidth) : Infinity
})
.toBeLessThan(2)
})
})

View File

@@ -24,8 +24,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
TestIds.canvas.toggleLinkVisibilityButton
)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
@@ -36,8 +36,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
.toBe(hiddenLinkRenderMode)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'canvas-with-visible-links.png'
)
await expect

View File

@@ -170,7 +170,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.workflow.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
await comfyPage.nextFrame()
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'
@@ -336,12 +335,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.page.evaluate(
(workflow) =>
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
currentGraphState
await comfyPage.workflow.loadGraphData(
currentGraphState as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
await verifyNodeLoaded(comfyPage, 1)
})
})

View File

@@ -60,7 +60,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -84,7 +83,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -107,7 +105,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')

View File

@@ -3,6 +3,7 @@ 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'
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -87,10 +88,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
test(
'Shows empty state when no images are set',
{ tag: '@smoke' },
@@ -98,7 +95,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByTestId('image-compare-empty')).toBeVisible()
await expect(node.getByTestId(TestIds.imageCompare.empty)).toBeVisible()
await expect(node.locator('img')).toHaveCount(0)
await expect(node.getByRole('presentation')).toHaveCount(0)
}
@@ -126,10 +123,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider defaults
// ---------------------------------------------------------------------------
test(
'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] },
@@ -164,10 +157,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider interaction
// ---------------------------------------------------------------------------
test(
'Mouse hover moves slider position',
{ tag: '@smoke' },
@@ -183,7 +172,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const handle = node.getByRole('presentation')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -224,7 +213,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -261,7 +250,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const compareArea = node.getByTestId('image-compare-viewport')
const compareArea = node.getByTestId(TestIds.imageCompare.viewport)
await expect(compareArea).toBeVisible()
await expect
@@ -292,10 +281,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBeCloseTo(100, 0)
})
// ---------------------------------------------------------------------------
// Single image modes
// ---------------------------------------------------------------------------
test('Only before image shows without slider when afterImages is empty', async ({
comfyPage
}) => {
@@ -324,10 +309,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(node.getByRole('presentation')).toBeHidden()
})
// ---------------------------------------------------------------------------
// Batch navigation
// ---------------------------------------------------------------------------
test(
'Batch navigation appears when before side has multiple images',
{ tag: '@smoke' },
@@ -342,13 +323,21 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
await expect(node.getByTestId('batch-nav')).toBeVisible()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
await expect(
node.getByTestId(TestIds.imageCompare.batchNav)
).toBeVisible()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 3')
// after-batch renders only when afterBatchCount > 1
await expect(node.getByTestId('after-batch')).toBeHidden()
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
await expect(
node.getByTestId(TestIds.imageCompare.afterBatch)
).toBeHidden()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeDisabled()
}
)
@@ -362,7 +351,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.getByTestId('batch-nav')).toBeHidden()
await expect(node.getByTestId(TestIds.imageCompare.batchNav)).toBeHidden()
})
test(
@@ -378,10 +367,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
await nextBtn.click()
await expect(counter).toHaveText('2 / 3')
@@ -407,10 +396,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
await nextBtn.click()
await nextBtn.click()
@@ -436,14 +425,18 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
await beforeBatch.getByTestId('batch-next').click()
await afterBatch.getByTestId('batch-next').click()
await beforeBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await afterBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 3')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 2')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
@@ -454,11 +447,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
})
// ---------------------------------------------------------------------------
// Node sizing
// ---------------------------------------------------------------------------
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
const minWidth = 400
const minHeight = 350
const size = await comfyPage.page.evaluate(() => {
const graphNode = window.app!.graph.getNodeById(1)
if (!graphNode?.size) return null
@@ -472,17 +463,13 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(
size.width,
'ImageCompare node minimum width'
).toBeGreaterThanOrEqual(400)
).toBeGreaterThanOrEqual(minWidth)
expect(
size.height,
'ImageCompare node minimum height'
).toBeGreaterThanOrEqual(350)
).toBeGreaterThanOrEqual(minHeight)
})
// ---------------------------------------------------------------------------
// Visual regression screenshots
// ---------------------------------------------------------------------------
for (const { pct, expectedClipMin, expectedClipMax } of [
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
@@ -500,7 +487,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeImg = node.locator('img[alt="Before image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await waitForImagesLoaded(node)
await expect(viewport).toBeVisible()
await moveToPercentage(comfyPage.page, viewport, pct)
@@ -516,10 +503,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
test('Widget handles image load failure gracefully', async ({
comfyPage
}) => {
@@ -586,9 +569,14 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByTestId('before-batch').getByTestId('batch-next').click()
await node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchNext)
.click()
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 2')
await setImageCompareValue(comfyPage, {
@@ -601,7 +589,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
green1Url
)
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 2')
})
@@ -656,23 +646,35 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
const beforeNext = beforeBatch.getByTestId('batch-next')
const afterNext = afterBatch.getByTestId('batch-next')
const beforeNext = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const afterNext = afterBatch.getByTestId(TestIds.imageCompare.batchNext)
for (let i = 0; i < 19; i++) {
await beforeNext.click()
await afterNext.click()
}
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(beforeBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(afterBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(beforeNext).toBeDisabled()
await expect(afterNext).toBeDisabled()
})

View File

@@ -0,0 +1,122 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Shows empty state when no input image is connected',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('No input image connected')).toBeVisible()
await expect(node.locator('img[alt="Crop preview"]')).toHaveCount(0)
}
)
test(
'Renders bounding box coordinate inputs',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('X')).toBeVisible()
await expect(node.getByText('Y')).toBeVisible()
await expect(node.getByText('Width')).toBeVisible()
await expect(node.getByText('Height')).toBeVisible()
}
)
test(
'Renders ratio selector and lock button',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(node.getByRole('button', { name: /lock/i })).toBeVisible()
}
)
test(
'Lock button toggles aspect ratio lock',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const lockButton = node.getByRole('button', {
name: 'Lock aspect ratio'
})
await expect(lockButton).toBeVisible()
await lockButton.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' })
).toBeVisible()
}
)
test(
'Ratio selector offers expected presets',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const trigger = node.getByRole('combobox')
await trigger.click()
const expectedRatios = ['1:1', '3:4', '4:3', '16:9', '9:16', 'Custom']
for (const label of expectedRatios) {
await expect(
comfyPage.page.getByRole('option', { name: label, exact: true })
).toBeVisible()
}
}
)
test(
'Programmatically setting widget value updates bounding box inputs',
{ tag: '@ui' },
async ({ comfyPage }) => {
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
await comfyPage.page.evaluate(
({ bounds }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget) {
widget.value = bounds
widget.callback?.(bounds)
}
},
{ bounds: newBounds }
)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeLocator('1')
const inputs = node.locator('input[inputmode="decimal"]')
await expect.poll(() => inputs.nth(0).inputValue()).toBe('50')
await expect.poll(() => inputs.nth(1).inputValue()).toBe('100')
await expect.poll(() => inputs.nth(2).inputValue()).toBe('200')
await expect.poll(() => inputs.nth(3).inputValue()).toBe('300')
}
)
})

View File

@@ -31,11 +31,9 @@ test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
})
})
@@ -76,13 +74,11 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node1.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode2
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node2.png')
}
)
@@ -174,8 +170,7 @@ test.describe('Node Interaction', () => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
maxDiffPixels: 50
})
})
@@ -185,7 +180,6 @@ test.describe('Node Interaction', () => {
// Pin this suite to the legacy canvas path so Alt+drag exercises
// LGraphCanvas, not the Vue node drag handler.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
})
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
@@ -285,7 +279,6 @@ test.describe('Node Interaction', () => {
}) => {
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
@@ -359,8 +352,8 @@ test.describe('Node Interaction', () => {
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'batch-disconnect-links-disconnected.png'
)
}
@@ -410,8 +403,8 @@ test.describe('Node Interaction', () => {
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
// Move mouse away to avoid hover highlight differences.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'text-encode-toggled-back-open.png'
)
}
@@ -514,8 +507,7 @@ test.describe('Node Interaction', () => {
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot(
'group-selected-nodes.png'
)
@@ -1171,8 +1163,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-middle-drag-pan.png'
)
})
@@ -1180,14 +1172,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-wheel-zoom-in.png'
)
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-wheel-zoom-out.png'
)
})
@@ -1247,8 +1239,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-middle-drag-pan.png'
)
})
@@ -1258,16 +1250,16 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-ctrl-wheel-zoom-in.png'
)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-ctrl-wheel-zoom-out.png'
)
})
@@ -1359,33 +1351,31 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
)
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'standard-initial.png')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-right.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, -240)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-left.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-center.png'
)
})

View File

@@ -112,9 +112,8 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
await comfyPage.expectScreenshot(
load3d.node,
'load3d-uploaded-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)
@@ -142,9 +141,8 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
await comfyPage.expectScreenshot(
load3d.node,
'load3d-dropped-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)

View File

@@ -0,0 +1,41 @@
import {
preview3dPipelineTest as test,
Preview3DPipelineContext
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
test.describe('Preview3D execution flow', { tag: ['@slow', '@node'] }, () => {
test('Preview3D loads model from execution output', async ({
preview3dPipeline: pipeline
}) => {
test.setTimeout(120_000)
await pipeline.seedLoad3dWithCubeObj()
await pipeline.queuePromptAndWaitIdle(90_000)
await pipeline.assertPreview3dExecutionOutputSettled()
await pipeline.assertPreview3dCanvasNonEmpty()
})
test('Preview3D restores last model and camera after save and full reload', async ({
preview3dPipeline: pipeline
}) => {
test.setTimeout(180_000)
await pipeline.seedLoad3dWithCubeObj()
await pipeline.setNonDefaultLoad3dCameraState()
await pipeline.queuePromptAndWaitIdle(90_000)
await pipeline.assertPreview3dExecutionOutputSettled()
await pipeline.nudgePreview3dCameraIntoProperties()
const savedPath = await pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
)
const savedCamera = await pipeline.getPreview3dCameraStateWhenReady()
const workflowName =
await pipeline.saveNamedWorkflowToSidebar('p3d-restore')
await pipeline.reloadPageAndWaitForAppShell()
await pipeline.openPersistedWorkflowFromSidebar(workflowName)
await pipeline.assertPreview3dModelPathAndLastTime(savedPath)
await pipeline.assertPreview3dCanvasNonEmpty()
await pipeline.assertPreview3dCameraRestored(savedCamera)
})
})

View File

@@ -1,3 +1,4 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
@@ -27,6 +28,85 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
}
async function openMaskEditorDialog(comfyPage: ComfyPage) {
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async function getMaskCanvasPixelData(page: Page) {
return page.evaluate(() => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
// The mask canvas is the 3rd canvas (index 2, z-30)
const maskCanvas = canvases[2] as HTMLCanvasElement
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
})
}
function pollMaskPixelCount(page: Page): Promise<number> {
return getMaskCanvasPixelData(page).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
async function drawStrokeOnPointerZone(
page: Page,
dialog: ReturnType<typeof page.locator>
) {
const pointerZone = dialog.locator(
'.maskEditor-ui-container [class*="w-[calc"]'
)
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(endX, endY, { steps: 10 })
await page.mouse.up()
return { startX, startY, endX, endY, box }
}
async function drawStrokeAndExpectPixels(
comfyPage: ComfyPage,
dialog: ReturnType<typeof comfyPage.page.locator>
) {
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
@@ -52,7 +132,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
}
)
@@ -79,9 +159,245 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
await expect(dialog).toHaveScreenshot(
await comfyPage.expectScreenshot(
dialog,
'mask-editor-dialog-from-context-menu.png'
)
}
)
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
expect(dataBefore!.nonTransparentPixels).toBe(0)
await drawStrokeAndExpectPixels(comfyPage, dialog)
})
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await expect(undoButton).toBeVisible()
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('redo restores an undone stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
const redoButton = dialog.locator('button[title="Redo"]')
await expect(redoButton).toBeVisible()
await redoButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
})
test('clear button removes all mask content', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await clearButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
await expect(dialog).toBeHidden()
})
test('invert button inverts the mask', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
const pixelsBefore = dataBefore!.nonTransparentPixels
const invertButton = dialog.getByRole('button', { name: 'Invert' })
await expect(invertButton).toBeVisible()
await invertButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(pixelsBefore)
})
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
await comfyPage.page.keyboard.press(modifier)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test(
'tool panel shows all five tools',
{ tag: ['@smoke'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolPanel = dialog.locator('.maskEditor-ui-container')
await expect(toolPanel).toBeVisible()
// The tool panel should contain exactly 5 tool entries
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// First tool (MaskPen) should be selected by default
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
}
)
test('switching tools updates the selected indicator', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// Click the third tool (Eraser, index 2)
await toolEntries.nth(2).click()
// The third tool should now be selected
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
// Verify it's the eraser (3rd entry)
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
})
test('brush settings panel is visible with thickness controls', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
// The side panel should show brush settings by default
const thicknessLabel = dialog.getByText('Thickness')
await expect(thicknessLabel).toBeVisible()
const opacityLabel = dialog.getByText('Opacity').first()
await expect(opacityLabel).toBeVisible()
const hardnessLabel = dialog.getByText('Hardness')
await expect(hardnessLabel).toBeVisible()
})
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
let maskUploadCount = 0
let imageUploadCount = 0
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-mask-${maskUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-image-${imageUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeVisible()
await saveButton.click()
await expect(dialog).toBeHidden()
// The save pipeline uploads multiple layers (mask + image variants)
expect(
maskUploadCount + imageUploadCount,
'save should trigger upload calls'
).toBeGreaterThan(0)
})
test('save failure keeps dialog open', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
route.fulfill({ status: 500 })
)
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill({ status: 500 })
)
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
// Dialog should remain open when save fails
await expect(dialog).toBeVisible()
})
test(
'eraser tool removes mask content',
{ tag: ['@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Draw a stroke with the mask pen (default tool)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
// Switch to eraser tool (3rd tool, index 2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await toolEntries.nth(2).click()
// Draw over the same area with the eraser
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
}
)
})

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
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'
@@ -16,21 +17,24 @@ function hasCanvasContent(canvas: Locator): Promise<boolean> {
})
}
async function clickMinimapAt(
overlay: Locator,
page: Page,
relX: number,
relY: number
) {
const box = await overlay.boundingBox()
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
function getMinimapLocators(comfyPage: ComfyPage) {
const container = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
return {
container,
canvas: comfyPage.page.getByTestId(TestIds.canvas.minimapCanvas),
viewport: comfyPage.page.getByTestId(TestIds.canvas.minimapViewport),
toggleButton: comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
),
closeButton: comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton)
}
}
// Click area — avoiding the settings button (top-left, 32×32px)
// and close button (top-right, 32×32px)
await page.mouse.click(
box!.x + box!.width * relX,
box!.y + box!.height * relY
)
function getCanvasOffset(page: Page): Promise<[number, number]> {
return page.evaluate(() => {
const ds = window.app!.canvas.ds
return [ds.offset[0], ds.offset[1]] as [number, number]
})
}
test.describe('Minimap', { tag: '@canvas' }, () => {
@@ -42,23 +46,13 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const { container, canvas, viewport } = getMinimapLocators(comfyPage)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
await expect(canvas).toBeVisible()
await expect(viewport).toBeVisible()
const minimapCanvas = minimapContainer.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.getByTestId(
TestIds.canvas.minimapViewport
)
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
await expect(container).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
@@ -69,59 +63,53 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const { container, toggleButton } = getMinimapLocators(comfyPage)
await expect(toggleButton).toBeVisible()
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const { container, toggleButton } = getMinimapLocators(comfyPage)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
await toggleButton.click()
await expect(minimapContainer).toBeHidden()
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await toggleButton.click()
await expect(minimapContainer).toBeVisible()
await comfyPage.nextFrame()
await expect(container).toBeVisible()
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const { container } = getMinimapLocators(comfyPage)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).toBeHidden()
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).toBeVisible()
await comfyPage.nextFrame()
await expect(container).toBeVisible()
})
test('Close button hides minimap', async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
await expect(minimap).toBeVisible()
const { container, toggleButton, closeButton } =
getMinimapLocators(comfyPage)
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).toBeHidden()
await expect(container).toBeVisible()
await closeButton.click()
await expect(container).toBeHidden()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
})
@@ -129,12 +117,10 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
await comfyPage.expectScreenshot(container, 'minimap-before-pan.png')
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
@@ -143,156 +129,192 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
await comfyPage.expectScreenshot(container, 'minimap-after-pan.png')
}
)
test('Minimap canvas is non-empty for a workflow with nodes', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await comfyPage.keyboard.selectAll()
await comfyPage.vueNodes.deleteSelected()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
})
test('Clicking minimap corner pans the main canvas', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
await expect(minimap).toBeVisible()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
await expect
.poll(() =>
comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
)
.not.toStrictEqual(before)
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transformBefore)
})
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(minimap).toBeVisible()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 1000
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await comfyPage.page.evaluate(() => {
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
})
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
timeout: 2000
})
.not.toBe(transformBefore)
await comfyPage.nextFrame()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
await comfyPage.nextFrame()
const after = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
const TOLERANCE = 50
expect(
Math.abs(after.x - before.x),
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
expect(
Math.abs(after.y - before.y),
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
})
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
await expect(async () => {
const vb = await viewport.boundingBox()
const mb = await minimap.boundingBox()
expect(vb).toBeTruthy()
expect(mb).toBeTruthy()
expect(vb!.width).toBeGreaterThan(0)
expect(vb!.height).toBeGreaterThan(0)
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
}).toPass({ timeout: 5000 })
const minimapBox = await container.boundingBox()
const viewportBox = await viewport.boundingBox()
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
expect(minimapBox).toBeTruthy()
expect(viewportBox).toBeTruthy()
expect(viewportBox!.width).toBeGreaterThan(0)
expect(viewportBox!.height).toBeGreaterThan(0)
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
minimapBox!.y
)
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
await comfyPage.expectScreenshot(container, 'minimap-with-viewport.png')
}
)
test('Clicking on minimap pans the canvas to that position', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const offsetBefore = await getCanvasOffset(comfyPage.page)
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
// Click the top-left quadrant — canvas should pan so that region
// becomes centered, meaning offset increases (moves right/down)
await comfyPage.page.mouse.click(
minimapBox!.x + minimapBox!.width * 0.2,
minimapBox!.y + minimapBox!.height * 0.2
)
await comfyPage.nextFrame()
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Dragging on minimap continuously pans the canvas', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
const startX = minimapBox!.x + minimapBox!.width * 0.3
const startY = minimapBox!.y + minimapBox!.height * 0.3
const endX = minimapBox!.x + minimapBox!.width * 0.7
const endY = minimapBox!.y + minimapBox!.height * 0.7
const offsetBefore = await getCanvasOffset(comfyPage.page)
// Drag from top-left toward bottom-right on the minimap
await comfyPage.page.mouse.move(startX, startY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(endX, endY, { steps: 10 })
// Mid-drag: offset should already differ from initial state
const offsetMidDrag = await getCanvasOffset(comfyPage.page)
expect(
offsetMidDrag[0] !== offsetBefore[0] ||
offsetMidDrag[1] !== offsetBefore[1]
).toBe(true)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
// Final offset should also differ (drag was not discarded on mouseup)
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Minimap viewport updates when canvas is zoomed', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const viewportBefore = await viewport.boundingBox()
expect(viewportBefore).toBeTruthy()
// Zoom in significantly
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.scale = 3
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// Viewport rectangle should shrink when zoomed in
await expect
.poll(async () => {
const box = await viewport.boundingBox()
return box?.width ?? 0
})
.toBeLessThan(viewportBefore!.width)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
// Minimap should have content before deletion
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
// Remove all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('Delete')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Minimap canvas should be empty — no nodes means nothing to render
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(false)
})
test('Minimap re-renders after loading a different workflow', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
// Default workflow has content
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
// Load a very different workflow
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.nextFrame()
// Minimap should still have content (different workflow, still has nodes)
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(true)
})
test('Minimap viewport position reflects canvas pan state', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const positionBefore = await viewport.boundingBox()
expect(positionBefore).toBeTruthy()
// Pan the canvas by a large amount to the right and down
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 500
canvas.ds.offset[1] -= 500
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// The viewport indicator should have moved within the minimap
await expect
.poll(async () => {
const box = await viewport.boundingBox()
if (!box || !positionBefore) return false
return box.x !== positionBefore.x || box.y !== positionBefore.y
})
.toBe(true)
})
})

View File

@@ -11,8 +11,10 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'mobile-empty-canvas.png'
)
})
test('@mobile default workflow', async ({ comfyPage }) => {
@@ -24,7 +26,6 @@ test.describe(
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.nextFrame()
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
@@ -38,9 +39,8 @@ test.describe(
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.settingDialog.root,
'mobile-settings-dialog.png',
{
mask: [

View File

@@ -13,7 +13,6 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
@@ -35,7 +34,6 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -14,7 +14,6 @@ async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -53,7 +52,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -110,8 +108,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -124,8 +121,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Delete')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -138,8 +134,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Backspace')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()

View File

@@ -303,8 +303,8 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'link-release-context-menu.png'
)
}

View File

@@ -370,4 +370,64 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
})
test.describe('Eraser', () => {
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
})
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(false)
})
})
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
})
})

View File

@@ -113,6 +113,40 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect(missingModelGroup).toBeVisible()
})
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
comfyPage
}) => {
// Regression: on un-bypass, the realtime scan produced a fresh
// candidate without url/hash/directory — those fields were only
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
// row's Copy URL button (v-if gated on representative.url) then
// disappeared. Per-node scan now enriches from node.properties.models
// which persists across mode toggles. Uses the `_from_node_properties`
// fixture because the enrichment source is per-node metadata, not
// the workflow-level `models[]` array (which the realtime scan
// path does not see).
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(copyUrlButton.first()).toBeVisible()
})
test('Pasting a node with missing model increases referencing node count', async ({
comfyPage
}) => {
@@ -476,6 +510,52 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
comfyPage
}) => {
// Regression: the initial scan pipeline only checked each node's
// own mode, so interior nodes of a bypassed subgraph container
// surfaced errors even though the container was excluded from
// execution. The pipeline now post-filters candidates whose
// ancestor path is not fully active.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_bypassed_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
comfyPage
}) => {
// Regression: useGraphNodeManager replays graph.onNodeAdded for
// each interior node on subgraph entry; without an ancestor-aware
// guard in scanSingleNodeErrors, that re-scan reintroduced the
// error that the initial pipeline had correctly suppressed.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_bypassed_subgraph'
)
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await expect(errorsTab).toBeHidden()
})
})
test.describe('Workflow switching', () => {

View File

@@ -19,8 +19,10 @@ test.describe(
await comfyPage.page.getByText('loaders').click()
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'add-node-node-added.png'
)
})
test('Can add group', async ({ comfyPage }) => {
@@ -28,8 +30,8 @@ test.describe(
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'add-group-group-added.png'
)
})
@@ -45,8 +47,8 @@ test.describe(
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-group-node.png'
)
})
@@ -60,12 +62,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-properties-panel.png'
)
})
@@ -76,12 +77,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-collapsed.png'
)
})
@@ -104,8 +104,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-collapsed-badge.png'
)
})
@@ -116,12 +116,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-bypassed.png'
)
})
@@ -133,8 +132,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
@@ -149,8 +147,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-pinned-node.png'
)
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
@@ -160,8 +158,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-unpinned-node.png'
)
})
@@ -206,8 +204,10 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-nodes-pinned.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'selected-nodes-pinned.png'
)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'
@@ -216,8 +216,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'selected-nodes-unpinned.png'
)
})

View File

@@ -11,15 +11,13 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
const totalCount = await comfyPage.vueNodes.getNodeCount()
// Use canvas press for keyboard shortcuts (doesn't need click target)
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect(comfyPage.vueNodes.selectedNodes).not.toHaveCount(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
@@ -70,8 +68,7 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())

View File

@@ -267,8 +267,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
.click()
// Undo the colorization
await comfyPage.page.keyboard.press('Control+Z')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+Z')
// Node should be uncolored again
const selectedNode = (

View File

@@ -32,7 +32,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('delete button removes selected node', async ({ comfyPage }) => {
@@ -69,7 +68,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -83,7 +81,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -160,7 +157,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -187,7 +183,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
@@ -229,7 +224,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Select the SaveImage node by panning to it
const saveImageRef = (

View File

@@ -14,7 +14,6 @@ test.describe(
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
@@ -43,7 +42,6 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -39,7 +39,6 @@ test.describe('Sidebar splitter width independence', () => {
location: 'left' | 'right'
) {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
await comfyPage.nextFrame()
await dismissToasts(comfyPage)
await comfyPage.menu.nodeLibraryTab.open()
}

View File

@@ -14,7 +14,6 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea

View File

@@ -37,7 +37,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
@@ -49,8 +48,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = (await breadcrumb.textContent()) ?? ''
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await comfyPage.canvas.dblclick({
position: {
@@ -64,8 +62,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Enter')
await subgraphNode.navigateIntoSubgraph()
await expect(breadcrumb).toBeVisible()
@@ -78,7 +75,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
@@ -90,13 +86,11 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
@@ -106,7 +100,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.describe('Navigation Hotkeys', () => {
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
@@ -135,7 +128,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -145,8 +137,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
@@ -154,8 +145,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
.toBe(true)
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+q')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -163,7 +153,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -183,8 +172,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
@@ -192,8 +180,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
@@ -205,7 +192,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
@@ -272,7 +258,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -296,8 +281,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect
.poll(() =>
@@ -312,7 +296,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -328,10 +311,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect
.poll(() =>

View File

@@ -18,7 +18,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

View File

@@ -38,13 +38,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+c')
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+v')
await expect
.poll(() => comfyPage.subgraph.getNodeCount())

View File

@@ -102,7 +102,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -150,7 +149,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
@@ -318,7 +316,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
@@ -403,7 +400,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
@@ -455,7 +451,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
await expect
@@ -476,7 +471,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
const initialNames = await getPromotedWidgetNames(comfyPage, '5')

View File

@@ -68,7 +68,6 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
@@ -88,8 +87,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()

View File

@@ -15,8 +15,7 @@ async function exitSubgraphAndPublish(
subgraphNode: Awaited<ReturnType<typeof createSubgraphAndNavigateInto>>,
blueprintName: string
) {
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {

View File

@@ -40,7 +40,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
@@ -59,7 +58,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
await expect
.poll(async () => {
@@ -73,20 +71,17 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -121,7 +116,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,

View File

@@ -424,7 +424,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()

View File

@@ -34,9 +34,8 @@ test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
{ message: 'All nodes should be within the visible viewport' }
)
.toBe(true)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'viewport-fits-when-saved-offscreen.png'
)
})

View File

@@ -121,8 +121,8 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-groups-create-group.png'
)
})
@@ -131,7 +131,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
await comfyPage.keyboard.selectAll()
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-fit-to-contents.png'
)

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