Compare commits

...

19 Commits

Author SHA1 Message Date
Terry Jia
b494392265 fix: record audio node not releasing microphone in Vue mode (#10829)
## Summary

Root cause: setting modelValue on recording completion triggers
NodeWidgets' updateHandler → widget.callback → litegraph recording
handler (getUserMedia), opening a second mic stream never cleaned up.

Fix:
- Stop writing to modelValue (keep defineModel to absorb parent v-model
without triggering the callback)
- On recording complete, set blob URL on litegraph audioUI DOM element
instead of uploading — let the original serializeValue (uploadAudio.ts)
handle upload at serialization time
- Remove registerWidgetSerialization to stop overriding litegraph's
serializeValue
- Move cleanup() before async onRecordingComplete in useAudioRecorder
- Dispose waveform AudioContext on stop

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/1e464ea1-53ed-44e2-973b-97eebc63fb76


after

https://github.com/user-attachments/assets/badc8a3f-0761-43bd-a899-d8924f413028

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10829-fix-record-audio-node-not-releasing-microphone-in-Vue-mode-3366d73d36508106b4a4dda31501ec4d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-08 04:31:40 -04:00
jaeone94
3f375bea9c test: comprehensive E2E tests for error dialog, overlay, and errors tab (#10848)
## Summary

Comprehensive Playwright E2E tests for the error systems: ErrorDialog,
ErrorOverlay, and the errors tab (missing nodes, models, media,
execution errors).

## Changes

- **What**:
- **ErrorDialog** (`errorDialog.spec.ts`, 7 tests): configure/prompt
error triggers, Show Report, Copy to Clipboard, Find Issues on GitHub,
Contact Support
- **ErrorOverlay** (`errorOverlay.spec.ts`, 12 tests): error count
labels, per-type button labels (missing nodes/models/media/multiple),
See Errors flow (open panel, dismiss, close), undo/redo persistence
- **Errors tab — common** (`errorsTab.spec.ts`, 3 tests): tab
visibility, search/filter execution errors
- **Errors tab — Missing nodes** (`errorsTabMissingNodes.spec.ts`, 5
tests): MissingNodeCard, packs group, expand/collapse, locate button
- **Errors tab — Missing models** (`errorsTabMissingModels.spec.ts`, 6
tests): group display, model name, expand/referencing nodes, clipboard
copy, OSS Copy URL/Download buttons
- **Errors tab — Missing media** (`errorsTabMissingMedia.spec.ts`, 7
tests): migrated from `missingMedia.spec.ts` with detection,
upload/library/cancel flows, locate
- **Errors tab — Execution** (`errorsTabExecution.spec.ts`, 2 tests):
Find on GitHub/Copy buttons, runtime error panel
- **Shared helpers**: `ErrorsTabHelper.ts` (openErrorsTabViaSeeErrors),
`clipboardSpy.ts` (interceptClipboardWrite/getClipboardText)
- **Component changes**: added `data-testid` to
`ErrorDialogContent.vue`, `FindIssueButton.vue`, `MissingModelRow.vue`,
`MissingModelCard.vue`
  - **Selectors**: registered all new test IDs in `selectors.ts`
- **Test assets**: `missing_nodes_and_media.json` (compound errors),
`missing_models_with_nodes.json` (expand/locate)
- **Migrations**: error tests from `dialog.spec.ts` → dedicated files,
`errorOverlaySeeErrors.spec.ts` → `errorOverlay.spec.ts`,
`missingMedia.spec.ts` → `errorsTabMissingMedia.spec.ts`

## Review Focus

- OSS tests (`@oss` tag) verify Download/Copy URL buttons appear for
models with embedded URLs.
- The `missing_models.json` fixture must remain without nodes — adding
`CheckpointLoaderSimple` nodes causes directory mismatch in
`enrichWithEmbeddedMetadata` that prevents URL enrichment. A separate
`missing_models_with_nodes.json` fixture is used for expand/locate
tests.

## Cloud tests deferred

Missing model cloud environment tests (`@cloud` tag — hidden buttons,
import-unsupported notice) are deferred to a follow-up PR. The
`comfyPage` fixture cannot bypass the Firebase auth guard in cloud
builds, causing `window.app` initialization timeout. A separate infra PR
is needed to add cloud auth bypass to the fixture.

## Bug Discovery

During testing, a bug was found where the **Locate button for missing
nodes in subgraphs fails on initial workflow load**.
`collectMissingNodes` in `loadGraphData` captures execution IDs using
pre-`configure()` JSON node IDs, but `configure()` triggers subgraph
node ID deduplication (PR #8762, always-on since PR #9510) which remaps
colliding IDs. This will be addressed in a follow-up PR.

- Fixes #10847 (tracked, fix pending in separate PR)

## Testing

- 42 new/migrated E2E tests across 8 spec files
- All OSS tests pass locally and in CI

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10848-test-comprehensive-E2E-tests-for-error-dialog-overlay-and-errors-tab-3386d73d36508137a5e4cec8b12fa2fa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:45:17 +09:00
Dante
c084089fc8 fix: update upload dialog status when async download completes (#10838)
## Summary

- Upload Model dialog stays stuck in "Processing" after async download
completes because the wizard never watches for task completion
- `useUploadModelWizard.ts` sets `uploadStatus = 'processing'` but has
no watcher linking it to `assetDownloadStore.lastCompletedDownload`
- Added a `watch` on `lastCompletedDownload` that transitions
`uploadStatus` to `'success'` when the tracked task finishes

- Fixes #10609

## Root Cause

`uploadModel()` (line 249) sets `uploadStatus = 'processing'` when the
async task starts, but control flow ends there. The `assetDownloadStore`
receives WebSocket completion events and updates
`lastCompletedDownload`, but the wizard never watches this reactive
state.

## Fix

Added a `watch` inside the async branch that monitors
`assetDownloadStore.lastCompletedDownload`. When the completed task ID
matches the upload's task ID, it transitions `uploadStatus` from
`'processing'` to `'success'` and refreshes model caches. The watcher
auto-disposes after firing.

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for upload dialog stuck in processing state` |
🔴 Red | Proves the test catches the bug |
| `fix: update upload dialog status when async download completes` |
🟢 Green | Proves the fix resolves the bug |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit test: `updates uploadStatus to success when async download
completes`
- [ ] Manual: Import model via URL → verify dialog transitions from
Processing to Success (requires `--enable-assets`)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10838-fix-update-upload-dialog-status-when-async-download-completes-3376d73d365081b6a5b1d2be3804ce4b)
by [Unito](https://www.unito.io)
2026-04-08 13:09:02 +09:00
Alexander Brown
4cb83353cb test: stabilize flaky Playwright tests (#10817)
Stabilize flaky Playwright tests by improving test reliability.

This PR aims to identify and fix flaky e2e tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10817-test-stabilize-flaky-Playwright-tests-3366d73d365081ada40de73ce11af625)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-07 19:47:27 -07:00
Terry Jia
d73c4406ed test: add basic E2E tests for Load3D node (#10731)
## Summary
Add Playwright tests covering widget rendering, controls menu
interaction, background color change, and recording controls visibility.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10731-test-add-basic-E2E-tests-for-Load3D-node-3336d73d36508194bff9eb2a7c9356b9)
by [Unito](https://www.unito.io)
2026-04-07 22:41:12 -04:00
Dante
ccdde8697c test: add E2E regression tests for workflow tab save bug (#10815)
## Summary

- Adds E2E regression tests for the bug fixed in PR #10745 where closing
an inactive workflow tab would save the active workflow's content into
the closing tab's file
- Three test scenarios covering the full range of the bug surface:
  1. Closing an unmodified inactive tab preserves both workflows
2. Closing a modified inactive tab with "Save" preserves its own content
(not the active tab's)
3. Closing an unsaved inactive tab with "Save As" preserves its own
content

## Linked Issues

- Regression coverage for #10745 / Comfy-Org/ComfyUI#13230

## Test plan

- [ ] `pnpm exec playwright test
browser_tests/tests/topbar/workflowTabSave.spec.ts` passes against the
current main (with the fix)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10815-test-add-E2E-regression-tests-for-workflow-tab-save-bug-3366d73d365081eab409ed303620a959)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 10:40:12 +09:00
Christian Byrne
194baf7aee fix(ci): restore pnpm version input for core/* branch compatibility (#10952)
## Summary

Restores `version: 10` to the `pnpm/action-setup` step in
`release-version-bump.yaml`.

## Why

PR #10687 removed the explicit `version: 10` input, relying on
`packageManager` in `package.json` instead. However, this workflow
checks out a **target branch** (e.g. `core/1.42`, `core/1.41`) that
predates #10687 and lacks the `packageManager` field — causing the
action to fail with:

> Error: No pnpm version is specified.

Failed run:
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24110739586

Adding `version: 10` back is safe — the action only errors when
`version` and `packageManager` **conflict**, and `pnpm@10.x` is
compatible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10952-fix-ci-restore-pnpm-version-input-for-core-branch-compatibility-33c6d73d3650819282bbf6c194d0d2f1)
by [Unito](https://www.unito.io)
2026-04-07 17:45:42 -07:00
Comfy Org PR Bot
5770837e07 1.43.15 (#10951)
Patch version increment to 1.43.15

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10951-1-43-15-33c6d73d36508183b029ccd217a8403d)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-07 17:24:14 -07:00
Johnpaul Chiwetelu
4078f8be8f test: add E2E test for subgraph duplicate independent widget values (#10949)
## Summary
- Add E2E test verifying duplicated subgraphs maintain independent
widget values (convert CLIP node to subgraph, duplicate, set different
text in each, assert no bleed)
- Extract `clickMenuItemExact` and `openForVueNode` into `ContextMenu`
fixture for reuse across Vue node tests
- Refactor `contextMenu.spec.ts` to delegate to the new fixture methods

## Test plan
- [x] `pnpm typecheck:browser` passes
- [x] `pnpm lint` passes
- [x] New test passes locally (`pnpm test:browser:local --
browser_tests/tests/subgraph/subgraphDuplicateIndependentValues.spec.ts`)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10949-test-add-E2E-test-for-subgraph-duplicate-independent-widget-values-33b6d73d3650818191c1f78ef8db4455)
by [Unito](https://www.unito.io)
2026-04-08 00:20:23 +01:00
Christian Byrne
ff0453416a ci: pass CODECOV_TOKEN and add codecov.yml for PR comments (#10774)
## What

Follow-up to #10575. Pass `CODECOV_TOKEN` secret to codecov upload
action and add `codecov.yml` config so Codecov posts coverage diff
comments on PRs.

## Changes

- `ci-tests-unit.yaml`: add `token: ${{ secrets.CODECOV_TOKEN }}`
- `codecov.yml`: configure PR comment layout (header, diff, flags,
files)

## Manual Step Required

An admin needs to add the `CODECOV_TOKEN` secret to the repo:

1. Go to [codecov.io](https://app.codecov.io) → sign in → find
`Comfy-Org/ComfyUI_frontend` → Settings → General → copy the Repository
Upload Token
2. Go to [repo
secrets](https://github.com/Comfy-Org/ComfyUI_frontend/settings/secrets/actions)
→ New repository secret → name: `CODECOV_TOKEN`, value: the token

## Testing

Config-only change. Once the secret is added, the next PR will get a
Codecov coverage comment.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10774-ci-pass-CODECOV_TOKEN-and-add-codecov-yml-for-PR-comments-3346d73d36508169bac5e61eecc94063)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-07 15:52:34 -07:00
Benjamin Lu
fd9c67ade8 test: type asset helper update payload (#10751)
What changed

- Typed the `PUT /assets/:id` mock body in `AssetHelper` as
`AssetUpdatePayload` instead of treating it as an untyped record.

Why

- Keeps the mock aligned with the frontend update contract used by the
asset service.
- Narrows the helper without changing behavior, so follow-up typing work
can build on a smaller base.

Validation

- `pnpm typecheck`
- `pnpm typecheck:browser`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10751-test-type-asset-helper-update-payload-3336d73d365081fd8f1bc0c7c49c5ddb)
by [Unito](https://www.unito.io)
2026-04-07 14:26:56 -07:00
Christian Byrne
83f4e7060a test(infra): AssetHelper with builder pattern + deterministic fixtures (#10545)
## What

Adds `AssetHelper` — a builder-pattern helper for mocking asset-related
API endpoints in Playwright E2E tests, plus deterministic fixture data.

## Why

12+ asset-related API endpoints need mocking for asset browser tests
(PNL-02), cloud dialog testing (DLG-08), and other asset-dependent E2E
scenarios. Random mock data from existing `createMockAssets()` is
unsuitable for deterministic E2E assertions.

## What's included

### `AssetHelper.ts` (307 LOC)
- Fluent builder API: `assetHelper.withModels(3).withImages(5).mock()`
- Stateful mock store (Map) for upload→verify→delete flows
- Endpoint coverage: GET/POST/PUT/DELETE `/assets`, download progress
- `mockError()` for error state testing
- `clearMocks()` cleanup matching QueueHelper/FeatureFlagHelper pattern

### `assetFixtures.ts` (304 LOC)
- 11 stable named constants (checkpoints, loras, VAE, embedding, inputs,
outputs)
- Factory functions: `generateModels()`, `generateInputFiles()`,
`generateOutputAssets()`
- Fixed IDs/dates/sizes — no randomness, safe for screenshot comparisons

### ComfyPage integration
- Available as `comfyPage.assets` in all tests

## Testing
- TypeScript compiles clean
- Follows existing QueueHelper/FeatureFlagHelper conventions

## Unblocks
- PNL-02: Asset browser tests (@Jaewon Yoon)
- DLG-08: Assets modal / cloud dialog testing

Part of: Test Coverage Q2 Overhaul

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10545-test-infra-AssetHelper-with-builder-pattern-deterministic-fixtures-32f6d73d365081d3985ef079ff3dbede)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-07 14:23:36 -07:00
AustinMroz
31c789c242 Support svg outputs in assets panel (#10470)
A trivial fix to support svg outputs in the assets panel

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/2fca84f7-40f1-4966-b3dd-96facb8a4067"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/cad1a9fc-f511-43bc-8895-80d931baad1c"
/>|

Note: SVG do not display on cloud in node, or the assets panel because
they are being served with the incorrect content-type of `text/plain`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10470-Support-svg-outputs-in-assets-panel-32d6d73d3650815fbba1fe788297e715)
by [Unito](https://www.unito.io)
2026-04-07 13:53:37 -07:00
pythongosssss
1b05927ff4 test: App mode - setting widget value test (#10746)
## Summary

Adds a test for setting various types of widgets in app mode, then
validating the /prompt API is called with the expected values

## Changes

- **What**: 
- extract duplicated enableLinearMode
- add AppModeWidgetHelper for setting values

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10746-test-App-mode-setting-widget-value-test-3336d73d365081739598fb5280d0127e)
by [Unito](https://www.unito.io)
2026-04-07 13:53:01 -07:00
AustinMroz
97853aa8b0 Update autogrow to always show one optional beyond min (#10748)
It can be a little unclear that autogrow inputs will add more slots as
connections are made. To assist with this, the first optional input
beyond the minimum is always displayed. This ensures users always see a
slot with an optional indicator.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/1dd1241e-c6a4-46a6-a0b9-08b568decd10"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/79650f9a-7cc6-4484-83a3-2b25e2f1af33"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10748-Update-autogrow-to-always-show-one-optional-beyond-min-3336d73d36508184bcc6c79381a62436)
by [Unito](https://www.unito.io)
2026-04-07 12:53:32 -07:00
AustinMroz
ac922fe6aa Remove flex styling from svg (#10941)
This cleans up a minor warning message from the dev server.:
` WARN  Removing unexpected style on "svg": flex`

The icon is displayed in the header of some templates and is not
visually impacted by the change.
<img width="268" height="365" alt="image"
src="https://github.com/user-attachments/assets/a0c03e85-5275-4671-b903-8458d7ba3517"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10941-Remove-flex-styling-from-svg-33b6d73d36508113a7dce77c269fe4cb)
by [Unito](https://www.unito.io)
2026-04-07 12:31:21 -07:00
pythongosssss
6f8e58bfa5 test: App Mode - widget reordering (#10685)
## Summary

Adds tests that simulate the user sorting the inputs and ensures they
are persisted and visible in app mode

## Changes

- **What**: 
- rework input/output selection helpers to accept widget/node names
- extract additional shared helpers

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10685-test-App-Mode-widget-reordering-3316d73d3650813b8348da5f29fd01f8)
by [Unito](https://www.unito.io)
2026-04-07 11:56:43 -07:00
Arthur R Longbottom
6cd3b59d5f fix: don't override loadGraphData viewport on cache miss (#10810)
## Summary

Fix regression from #10247 where template workflows (e.g. LTX2.3) loaded
with a broken viewport.

## Problem

`restoreViewport()` called `fitView()` on every cache miss via rAF. This
raced with `loadGraphData`'s own viewport restore (`extra.ds` for saved
workflows, or its own `fitView()` for templates at line 1266 of app.ts).
The second `fitView()` overwrote the correct viewport, causing templates
with subgraphs to display incorrectly.

## Fix

On cache miss, check if any nodes are already visible in the current
viewport before calling `fitView()`. If `loadGraphData` already
positioned things correctly, we don't override it. Only intervene when
the viewport is genuinely empty (first visit to a subgraph with no prior
cached state AND no loadGraphData restore).

## Review Focus

Single-file change in `subgraphNavigationStore.ts`. The visibility check
mirrors the same pattern used in `app.ts:1272-1281` where loadGraphData
itself checks for visible nodes.

## E2E Regression Test

The existing Playwright tests in
`browser_tests/tests/subgraphViewport.spec.ts` (added in #10247) already
cover viewport restoration after subgraph navigation. The specific
regression (template load viewport race) is not practically testable in
E2E because:
1. Template loading requires the backend's template API which returns
different templates per environment
2. The race condition depends on exact timing between `loadGraphData`'s
viewport restore and the rAF-deferred `restoreViewport` — Playwright
cannot reliably reproduce frame-level timing races
3. The fix is a guard condition (skip fitView if nodes visible) that
makes the behavior idempotent regardless of timing

## Alternative to #10790

This can replace the full revert in #10790 — it preserves the viewport
persistence feature while fixing the template regression.

Fixes regression from #10247
2026-04-07 10:01:54 -07:00
pythongosssss
0b83926c3e fix: Ensure zero uuid root graphs get assigned a valid id (#10825)
## Summary

Fixes an issue where handlers would be leaked causing Vue node rendering
to be corrupted (Vue nodes would not render) due to the
00000000-0000-0000-0000-000000000000 ID being used on the root graph.

## Changes

- **What**: 
- LGraph clear() skips store cleanup for the zero uuid, leaking handlers
that cause the node manager/handlers to be overwritten during operations
such as undo due to stale onNodeAdded hooks
- Ensures that graph configuration assigns a valid ID for root graphs

## Screenshots (if applicable)

Before fix, after doing ctrl+z after entering subgraph
<img width="1011" height="574" alt="image"
src="https://github.com/user-attachments/assets/1ff4692b-b961-4777-bf2d-9b981e311f91"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10825-fix-Ensure-zero-uuid-root-graphs-get-assigned-a-valid-id-3366d73d3650817d8603c71ffb5e5742)
by [Unito](https://www.unito.io)

---------

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-07 08:50:13 -07:00
91 changed files with 4415 additions and 1189 deletions

View File

@@ -31,4 +31,5 @@ jobs:
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

View File

@@ -144,6 +144,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -1,6 +1,8 @@
# E2E Testing Guidelines
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
See `@browser_tests/FLAKE_PREVENTION_RULES.md` when triaging or editing
flaky browser tests.
## Directory Structure

View File

@@ -0,0 +1,99 @@
# Browser Test Flake Prevention Rules
Reference this file as `@browser_tests/FLAKE_PREVENTION_RULES.md` when
debugging or updating flaky Playwright tests.
These rules are distilled from the PR 10817 stabilization thread chain. They
exist to make flaky-test triage faster and more repeatable.
## Quick Checklist
Before merging a flaky-test fix, confirm all of these are true:
- the latest CI artifact was inspected directly
- the root cause is stated as a race or readiness mismatch
- the fix waits on the real readiness boundary
- the assertion primitive matches the job
- the fix stays local unless a shared helper truly owns the race
- local verification uses a targeted rerun
## 1. Start With CI Evidence
- Do not trust the top-level GitHub check result alone.
- Inspect the latest Playwright `report.json` directly, even on a green run.
- Treat tests marked `flaky` in `report.json` as real work.
- Use `error-context.md`, traces, and page snapshots before editing code.
- Pull the newest run after each push instead of assuming the flaky set is
unchanged.
## 2. Wait For The Real Readiness Boundary
- Visible is not always ready.
- If the behavior depends on internal state, wait on that state.
- After canvas interactions, call `await comfyPage.nextFrame()` unless the
helper already guarantees a settled frame.
- After workflow reloads or node-definition refreshes, wait for the reload to
finish before continuing.
Common readiness boundaries:
- `node.imgs` populated before opening image context menus
- settings cleanup finished before asserting persisted state
- locale-triggered workflow reload finished before selecting nodes
- real builder UI ready, not transient helper metadata
## 3. Choose The Smallest Correct Assertion
- Use built-in retrying locator assertions when locator state is the behavior.
- Use `expect.poll()` for a single async value.
- Use `expect(async () => { ... }).toPass()` only when multiple assertions must
settle together.
- Do not make immediate assertions after async UI mutations, settings writes,
clipboard writes, or graph updates.
- Never use `waitForTimeout()` to hide a race.
```ts
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2'))
.toEqual([])
```
## 4. Prefer Behavioral Assertions
- Use screenshots only when appearance is the behavior under test.
- If a screenshot only indirectly proves behavior, replace it with a direct
assertion.
- Prefer assertions on link counts, positions, visible menu items, persisted
settings, and node state.
## 5. Keep Helper Changes Narrow
- Shared helpers should drive setup to a stable boundary.
- Do not encode one-spec timing assumptions into generic helpers.
- If a race only matters to one spec, prefer a local wait in that spec.
- If a helper fails before the real test begins, remove or relax the brittle
precondition and let downstream UI interaction prove readiness.
## 6. Verify Narrowly
- Prefer targeted reruns through `pnpm test:browser:local`.
- On Windows, prefer `file:line` or whole-spec arguments over `--grep` when the
wrapper has quoting issues.
- Use `--repeat-each 5` for targeted flake verification unless the failure needs
a different reproduction pattern.
- Verify with the smallest command that exercises the flaky path.
## Current Local Noise
These are local distractions, not automatic CI root causes:
- missing local input fixture files required by the test path
- missing local models directory
- teardown `EPERM` while restoring the local browser-test user data directory
- local screenshot baseline differences on Windows
Rules for handling local noise:
- first confirm whether it blocks the exact flaky path under investigation
- do not commit temporary local assets used only for verification
- do not commit local screenshot baselines

View File

@@ -0,0 +1,47 @@
{
"last_node_id": 1,
"last_link_id": 0,
"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",
"type": "MESH",
"links": null
}
],
"properties": {
"Node name for S&R": "Load3D"
},
"widgets_values": ["", 1024, 1024, "#000000"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,85 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [315, 98],
"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.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [500, 100],
"size": [315, 98],
"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.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,72 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "UNKNOWN NODE",
"pos": [48, 86],
"size": [358, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null,
"slot_index": 0
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [],
"slot_index": 0,
"shape": 6
}
],
"properties": {
"Node name for S&R": "UNKNOWN NODE"
},
"widgets_values": ["wd-v1-4-moat-tagger-v2", 0.35, 0.85, false, false, ""]
},
{
"id": 10,
"type": "LoadImage",
"pos": [450, 86],
"size": [315, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,197 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"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": [144.15234375, 46],
"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": {},
"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

@@ -29,6 +29,8 @@ import {
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
@@ -177,6 +179,7 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
/** Worker index to test user ID */
@@ -227,6 +230,7 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
}
@@ -448,6 +452,7 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
@@ -63,17 +64,13 @@ export class ComfyNodeSearchBox {
await this.input.waitFor({ state: 'visible' })
await this.input.fill(nodeName)
await this.dropdown.waitFor({ state: 'visible' })
if (options?.exact) {
await this.dropdown
.locator(`li[aria-label="${nodeName}"]`)
.first()
.click()
} else {
await this.dropdown
.locator('li')
.nth(options?.suggestionIndex || 0)
.click()
}
const nodeOption = options?.exact
? this.dropdown.locator(`li[aria-label="${nodeName}"]`).first()
: this.dropdown.locator('li').nth(options?.suggestionIndex ?? 0)
await expect(nodeOption).toBeVisible()
await nodeOption.click()
}
async addFilter(filterValue: string, filterType: string) {

View File

@@ -20,6 +20,10 @@ export class ContextMenu {
await this.page.getByRole('menuitem', { name }).click()
}
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
}
async clickLitegraphMenuItem(name: string): Promise<void> {
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
}
@@ -48,6 +52,18 @@ export class ContextMenu {
return this
}
/**
* Select a Vue node by clicking its header, then right-click to open
* the context menu. Vue nodes require a selection click before the
* right-click so the correct per-node menu items appear.
*/
async openForVueNode(header: Locator): Promise<this> {
await header.click()
await header.click({ button: 'right' })
await this.primeVueMenu.waitFor({ state: 'visible' })
return this
}
async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()

View File

@@ -0,0 +1,306 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-model-001',
name: 'model.safetensors',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2025-01-15T10:00:00Z',
updated_at: '2025-01-15T10:00:00Z',
last_access_time: '2025-01-15T10:00:00Z',
user_metadata: { base_model: 'sd15' },
...overrides
}
}
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-input-001',
name: 'input.png',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-01T09:00:00Z',
updated_at: '2025-03-01T09:00:00Z',
last_access_time: '2025-03-01T09:00:00Z',
...overrides
}
}
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-output-001',
name: 'output_00001.png',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:00:00Z',
updated_at: '2025-03-10T12:00:00Z',
last_access_time: '2025-03-10T12:00:00Z',
...overrides
}
}
export const STABLE_CHECKPOINT: Asset = createModelAsset({
id: 'test-checkpoint-001',
name: 'sd_xl_base_1.0.safetensors',
size: 6_938_078_208,
tags: ['models', 'checkpoints'],
user_metadata: {
base_model: 'sdxl',
description: 'Stable Diffusion XL Base 1.0'
},
created_at: '2025-01-15T10:30:00Z',
updated_at: '2025-01-15T10:30:00Z'
})
export const STABLE_CHECKPOINT_2: Asset = createModelAsset({
id: 'test-checkpoint-002',
name: 'v1-5-pruned-emaonly.safetensors',
size: 4_265_146_304,
tags: ['models', 'checkpoints'],
user_metadata: {
base_model: 'sd15',
description: 'Stable Diffusion 1.5 Pruned EMA-Only'
},
created_at: '2025-01-20T08:00:00Z',
updated_at: '2025-01-20T08:00:00Z'
})
export const STABLE_LORA: Asset = createModelAsset({
id: 'test-lora-001',
name: 'detail_enhancer_v1.2.safetensors',
size: 184_549_376,
tags: ['models', 'loras'],
user_metadata: {
base_model: 'sdxl',
description: 'Detail Enhancement LoRA'
},
created_at: '2025-02-20T14:00:00Z',
updated_at: '2025-02-20T14:00:00Z'
})
export const STABLE_LORA_2: Asset = createModelAsset({
id: 'test-lora-002',
name: 'add_detail_v2.safetensors',
size: 226_492_416,
tags: ['models', 'loras'],
user_metadata: {
base_model: 'sd15',
description: 'Add Detail LoRA v2'
},
created_at: '2025-02-25T11:00:00Z',
updated_at: '2025-02-25T11:00:00Z'
})
export const STABLE_VAE: Asset = createModelAsset({
id: 'test-vae-001',
name: 'sdxl_vae.safetensors',
size: 334_641_152,
tags: ['models', 'vae'],
user_metadata: {
base_model: 'sdxl',
description: 'SDXL VAE'
},
created_at: '2025-01-18T16:00:00Z',
updated_at: '2025-01-18T16:00:00Z'
})
export const STABLE_EMBEDDING: Asset = createModelAsset({
id: 'test-embedding-001',
name: 'bad_prompt_v2.pt',
size: 32_768,
mime_type: 'application/x-pytorch',
tags: ['models', 'embeddings'],
user_metadata: {
base_model: 'sd15',
description: 'Negative Embedding: Bad Prompt v2'
},
created_at: '2025-02-01T09:30:00Z',
updated_at: '2025-02-01T09:30:00Z'
})
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
id: 'test-input-001',
name: 'reference_photo.png',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-01T09:00:00Z',
updated_at: '2025-03-01T09:00:00Z'
})
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
id: 'test-input-002',
name: 'mask_layer.png',
size: 1_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-05T10:00:00Z',
updated_at: '2025-03-05T10:00:00Z'
})
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
id: 'test-input-003',
name: 'clip_720p.mp4',
size: 15_728_640,
mime_type: 'video/mp4',
tags: ['input'],
created_at: '2025-03-08T14:30:00Z',
updated_at: '2025-03-08T14:30:00Z'
})
export const STABLE_OUTPUT: Asset = createOutputAsset({
id: 'test-output-001',
name: 'ComfyUI_00001_.png',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:00:00Z',
updated_at: '2025-03-10T12:00:00Z'
})
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
id: 'test-output-002',
name: 'ComfyUI_00002_.png',
size: 3_670_016,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:05:00Z',
updated_at: '2025-03-10T12:05:00Z'
})
export const ALL_MODEL_FIXTURES: Asset[] = [
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2,
STABLE_LORA,
STABLE_LORA_2,
STABLE_VAE,
STABLE_EMBEDDING
]
export const ALL_INPUT_FIXTURES: Asset[] = [
STABLE_INPUT_IMAGE,
STABLE_INPUT_IMAGE_2,
STABLE_INPUT_VIDEO
]
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
const CHECKPOINT_NAMES = [
'sd_xl_base_1.0.safetensors',
'v1-5-pruned-emaonly.safetensors',
'sd_xl_refiner_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVision_v51.safetensors',
'deliberate_v3.safetensors',
'anything_v5.safetensors',
'counterfeit_v3.safetensors',
'revAnimated_v122.safetensors',
'majicmixRealistic_v7.safetensors'
]
const LORA_NAMES = [
'detail_enhancer_v1.2.safetensors',
'add_detail_v2.safetensors',
'epi_noiseoffset_v2.safetensors',
'lcm_lora_sdxl.safetensors',
'film_grain_v1.safetensors',
'sharpness_fix_v2.safetensors',
'better_hands_v1.safetensors',
'smooth_skin_v3.safetensors',
'color_pop_v1.safetensors',
'bokeh_effect_v2.safetensors'
]
const INPUT_NAMES = [
'reference_photo.png',
'mask_layer.png',
'clip_720p.mp4',
'depth_map.png',
'control_pose.png',
'sketch_input.jpg',
'inpainting_mask.png',
'style_reference.png',
'batch_001.png',
'batch_002.png'
]
const EXTENSION_MIME_MAP: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
flac: 'audio/flac'
}
function getMimeType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
return EXTENSION_MIME_MAP[ext] ?? 'application/octet-stream'
}
/**
* Generate N deterministic model assets of a given category.
* Uses sequential IDs and fixed names for screenshot stability.
*/
export function generateModels(
count: number,
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
): Asset[] {
const names = category === 'loras' ? LORA_NAMES : CHECKPOINT_NAMES
return Array.from({ length: Math.min(count, names.length) }, (_, i) =>
createModelAsset({
id: `gen-${category}-${String(i + 1).padStart(3, '0')}`,
name: names[i % names.length],
size: 2_000_000_000 + i * 500_000_000,
tags: ['models', category],
user_metadata: { base_model: i % 2 === 0 ? 'sdxl' : 'sd15' },
created_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`,
updated_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`
})
)
}
/**
* Generate N deterministic input file assets.
*/
export function generateInputFiles(count: number): Asset[] {
return Array.from({ length: Math.min(count, INPUT_NAMES.length) }, (_, i) => {
const name = INPUT_NAMES[i % INPUT_NAMES.length]
return createInputAsset({
id: `gen-input-${String(i + 1).padStart(3, '0')}`,
name,
size: 1_000_000 + i * 500_000,
mime_type: getMimeType(name),
tags: ['input'],
created_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`,
updated_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`
})
})
}
/**
* Generate N deterministic output assets.
*/
export function generateOutputAssets(count: number): Asset[] {
return Array.from({ length: count }, (_, i) =>
createOutputAsset({
id: `gen-output-${String(i + 1).padStart(3, '0')}`,
name: `ComfyUI_${String(i + 1).padStart(5, '0')}_.png`,
size: 3_000_000 + i * 200_000,
mime_type: 'image/png',
tags: ['output'],
created_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`,
updated_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`
})
)
}

View File

@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import { AppModeWidgetHelper } from './AppModeWidgetHelper'
import { BuilderFooterHelper } from './BuilderFooterHelper'
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
import { BuilderSelectHelper } from './BuilderSelectHelper'
@@ -13,18 +14,31 @@ export class AppModeHelper {
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly widgets: AppModeWidgetHelper
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
}
private get page(): Page {
return this.comfyPage.page
}
/** Enable the linear mode feature flag and top menu. */
async enableLinearMode() {
await this.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
@@ -91,6 +105,13 @@ export class AppModeHelper {
.first()
}
/** The Run button in the app mode footer. */
get runButton(): Locator {
return this.page
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

@@ -0,0 +1,93 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
*
* Widgets are located by their key (format: "nodeId:widgetName") via the
* `data-widget-key` attribute on each widget item.
*/
export class AppModeWidgetHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get container(): Locator {
return this.comfyPage.appMode.linearWidgets
}
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
getWidgetItem(key: string): Locator {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)
await widget.locator('textarea').fill(value)
}
/**
* Set a number input widget value (INT or FLOAT).
* Targets the last input inside the widget — this works for both
* ScrubableNumberInput (single input) and slider+InputNumber combos
* (last input is the editable number field).
*/
async fillNumber(key: string, value: string) {
const widget = this.getWidgetItem(key)
const input = widget.locator('input').last()
await input.fill(value)
await input.press('Enter')
}
/** Fill a string text input widget (e.g. filename_prefix). */
async fillText(key: string, value: string) {
const widget = this.getWidgetItem(key)
await widget.locator('input').fill(value)
}
/** Select an option from a combo/select widget. */
async selectOption(key: string, optionName: string) {
const widget = this.getWidgetItem(key)
await widget.getByRole('combobox').click()
await this.page
.getByRole('option', { name: optionName, exact: true })
.click()
}
/**
* Intercept the /api/prompt POST, click Run, and return the prompt payload.
* Fulfills the route with a mock success response.
*/
async runAndCapturePrompt(): Promise<
Record<string, { inputs: Record<string, unknown> }>
> {
let promptBody: Record<string, { inputs: Record<string, unknown> }> | null =
null
await this.page.route(
'**/api/prompt',
async (route, req) => {
promptBody = req.postDataJSON().prompt
await route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: 'test-id',
number: 1,
node_errors: {}
})
})
},
{ times: 1 }
)
const responsePromise = this.page.waitForResponse('**/api/prompt')
await this.comfyPage.appMode.runButton.click()
await responsePromise
if (!promptBody) throw new Error('No prompt payload captured')
return promptBody
}
}

View File

@@ -0,0 +1,317 @@
import type { Page, Route } from '@playwright/test'
import type {
Asset,
ListAssetsResponse,
UpdateAssetData
} from '@comfyorg/ingest-types'
import {
generateModels,
generateInputFiles,
generateOutputAssets
} from '../data/assetFixtures'
export interface MutationRecord {
endpoint: string
method: string
url: string
body: unknown
timestamp: number
}
interface PaginationOptions {
total: number
hasMore: boolean
}
export interface AssetConfig {
readonly assets: ReadonlyMap<string, Asset>
readonly pagination: PaginationOptions | null
readonly uploadResponse: Record<string, unknown> | null
}
function emptyConfig(): AssetConfig {
return { assets: new Map(), pagination: null, uploadResponse: null }
}
export type AssetOperator = (config: AssetConfig) => AssetConfig
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
const merged = new Map(config.assets)
for (const asset of newAssets) {
merged.set(asset.id, asset)
}
return { ...config, assets: merged }
}
export function withModels(
countOrAssets: number | Asset[],
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
): AssetOperator {
return (config) => {
const assets =
typeof countOrAssets === 'number'
? generateModels(countOrAssets, category)
: countOrAssets
return addAssets(config, assets)
}
}
export function withInputFiles(countOrAssets: number | Asset[]): AssetOperator {
return (config) => {
const assets =
typeof countOrAssets === 'number'
? generateInputFiles(countOrAssets)
: countOrAssets
return addAssets(config, assets)
}
}
export function withOutputAssets(
countOrAssets: number | Asset[]
): AssetOperator {
return (config) => {
const assets =
typeof countOrAssets === 'number'
? generateOutputAssets(countOrAssets)
: countOrAssets
return addAssets(config, assets)
}
}
export function withAsset(asset: Asset): AssetOperator {
return (config) => addAssets(config, [asset])
}
export function withPagination(options: PaginationOptions): AssetOperator {
return (config) => ({ ...config, pagination: options })
}
export function withUploadResponse(
response: Record<string, unknown>
): AssetOperator {
return (config) => ({ ...config, uploadResponse: response })
}
export class AssetHelper {
private store: Map<string, Asset>
private paginationOptions: PaginationOptions | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
private mutations: MutationRecord[] = []
private uploadResponse: Record<string, unknown> | null
constructor(
private readonly page: Page,
config: AssetConfig = emptyConfig()
) {
this.store = new Map(config.assets)
this.paginationOptions = config.pagination
this.uploadResponse = config.uploadResponse
}
async mock(): Promise<void> {
const handler = async (route: Route) => {
const url = new URL(route.request().url())
const method = route.request().method()
const path = url.pathname
const isMutation = ['POST', 'PUT', 'DELETE'].includes(method)
let body: Record<string, unknown> | null = null
if (isMutation) {
try {
body = route.request().postDataJSON()
} catch {
body = null
}
}
if (isMutation) {
this.mutations.push({
endpoint: path,
method,
url: route.request().url(),
body,
timestamp: Date.now()
})
}
if (method === 'GET' && /\/assets\/?$/.test(path))
return this.handleListAssets(route, url)
if (method === 'GET' && /\/assets\/[^/]+$/.test(path))
return this.handleGetAsset(route, path)
if (method === 'PUT' && /\/assets\/[^/]+$/.test(path))
return this.handleUpdateAsset(route, path, body)
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
return this.handleDeleteAsset(route, path)
if (method === 'POST' && /\/assets\/?$/.test(path))
return this.handleUploadAsset(route)
if (method === 'POST' && path.endsWith('/assets/download'))
return this.handleDownloadAsset(route)
return route.fallback()
}
const pattern = '**/assets**'
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
async mockError(
statusCode: number,
error: string = 'Internal Server Error'
): Promise<void> {
const handler = async (route: Route) => {
return route.fulfill({
status: statusCode,
json: { error }
})
}
const pattern = '**/assets**'
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
async fetch(
path: string,
init?: RequestInit
): Promise<{ status: number; body: unknown }> {
return this.page.evaluate(
async ([fetchUrl, fetchInit]) => {
const res = await fetch(fetchUrl, fetchInit)
const text = await res.text()
let body: unknown
try {
body = JSON.parse(text)
} catch {
body = text
}
return { status: res.status, body }
},
[path, init] as const
)
}
configure(...operators: AssetOperator[]): void {
const config = operators.reduce<AssetConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
this.store = new Map(config.assets)
this.paginationOptions = config.pagination
this.uploadResponse = config.uploadResponse
}
getMutations(): MutationRecord[] {
return [...this.mutations]
}
getAssets(): Asset[] {
return [...this.store.values()]
}
getAsset(id: string): Asset | undefined {
return this.store.get(id)
}
get assetCount(): number {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
const response: ListAssetsResponse = {
assets: filtered,
total: this.paginationOptions?.total ?? this.store.size,
has_more: this.paginationOptions?.hasMore ?? false
}
return route.fulfill({ json: response })
}
private handleGetAsset(route: Route, path: string) {
const id = path.split('/').pop()!
const asset = this.store.get(id)
if (asset) return route.fulfill({ json: asset })
return route.fulfill({ status: 404, json: { error: 'Not found' } })
}
private handleUpdateAsset(
route: Route,
path: string,
body: UpdateAssetData['body'] | null
) {
const id = path.split('/').pop()!
const asset = this.store.get(id)
if (asset) {
const updated = {
...asset,
...(body ?? {}),
updated_at: new Date().toISOString()
}
this.store.set(id, updated)
return route.fulfill({ json: updated })
}
return route.fulfill({ status: 404, json: { error: 'Not found' } })
}
private handleDeleteAsset(route: Route, path: string) {
const id = path.split('/').pop()!
this.store.delete(id)
return route.fulfill({ status: 204, body: '' })
}
private handleUploadAsset(route: Route) {
const response = this.uploadResponse ?? {
id: `upload-${Date.now()}`,
name: 'uploaded_file.safetensors',
tags: ['models', 'checkpoints'],
created_at: new Date().toISOString(),
created_new: true
}
return route.fulfill({ status: 201, json: response })
}
private handleDownloadAsset(route: Route) {
return route.fulfill({
status: 202,
json: {
task_id: 'download-task-001',
status: 'created',
message: 'Download started'
}
})
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.store.clear()
this.mutations = []
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(tags: string[]): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
)
}
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]
): AssetHelper {
const config = operators.reduce<AssetConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new AssetHelper(page, config)
}

View File

@@ -1,9 +1,36 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import type { NodeReference } from '../utils/litegraphUtils'
import { TestIds } from '../selectors'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}
export class BuilderSelectHelper {
constructor(private readonly comfyPage: ComfyPage) {}
@@ -99,41 +126,69 @@ export class BuilderSelectHelper {
await this.comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async selectInputWidget(node: NodeReference) {
/**
* Click a widget on the canvas to select it as a builder input.
* @param nodeTitle The displayed title of the node.
* @param widgetName The widget name to click.
*/
async selectInputWidget(nodeTitle: string, widgetName: string) {
await this.comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await this.page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
const nodeRef = (
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
)[0]
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
await nodeRef.centerOnNode()
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true })
await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
/** Click the first SaveImage/PreviewImage node on the canvas. */
async selectOutputNode() {
const saveImageNodeId = await this.page.evaluate(() => {
const node = window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
return node ? String(node.id) : null
})
if (!saveImageNodeId)
throw new Error('SaveImage/PreviewImage node not found')
const saveImageRef =
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
/** All IoItem title locators in the inputs step sidebar. */
get inputItemTitles(): Locator {
return this.page.getByTestId(TestIds.builder.ioItemTitle)
}
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await this.page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
/** All widget label locators in the preview/arrange sidebar. */
get previewWidgetLabels(): Locator {
return this.page.getByTestId(TestIds.builder.widgetLabel)
}
/**
* Drag an IoItem from one index to another in the inputs step.
* Items are identified by their 0-based position among visible IoItems.
*/
async dragInputItem(fromIndex: number, toIndex: number) {
const items = this.page.getByTestId(TestIds.builder.ioItem)
await dragByIndex(items, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
/**
* Drag a widget item from one index to another in the preview/arrange step.
*/
async dragPreviewItem(fromIndex: number, toIndex: number) {
const items = this.page.getByTestId(TestIds.builder.widgetItem)
await dragByIndex(items, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
/**
* Click an output node on the canvas to select it as a builder output.
* @param nodeTitle The displayed title of the output node.
*/
async selectOutputNode(nodeTitle: string) {
await this.comfyPage.canvasOps.setScale(1)
const nodeRef = (
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
)[0]
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
await nodeRef.centerOnNode()
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
String(nodeRef.id)
)
await nodeLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
}

View File

@@ -41,10 +41,21 @@ export const TestIds = {
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
errorDialogShowReport: 'error-dialog-show-report',
errorDialogContactSupport: 'error-dialog-contact-support',
errorDialogCopyReport: 'error-dialog-copy-report',
errorDialogFindIssues: 'error-dialog-find-issues',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelLocate: 'missing-model-locate',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
@@ -111,7 +122,12 @@ export const TestIds = {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu',
opensAs: 'builder-opens-as'
opensAs: 'builder-opens-as',
widgetItem: 'builder-widget-item',
widgetLabel: 'builder-widget-label'
},
appMode: {
widgetItem: 'app-mode-widget-item'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
@@ -149,6 +165,7 @@ export type TestIdValue =
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],

View File

@@ -1,10 +1,16 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { comfyExpect } from '../fixtures/ComfyPage'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
interface BuilderSetupResult {
inputNodeTitle: string
widgetNames: string[]
}
/**
* Enter builder on the default workflow and select I/O.
@@ -13,55 +19,97 @@ import { getPromotedWidgetNames } from './promotedWidgets'
* to subgraph), then enters builder mode and selects inputs + outputs.
*
* @param comfyPage - The page fixture.
* @param getInputNode - Returns the node to click for input selection.
* Receives the KSampler node ref and can transform the graph before
* returning the target node. Defaults to using KSampler directly.
* @returns The node used for input selection.
* @param prepareGraph - Optional callback to transform the graph before
* entering builder. Receives the KSampler node ref and returns the
* input node title and widget names to select.
* Defaults to KSampler with its first widget.
* Mutually exclusive with widgetNames.
* @param widgetNames - Widget names to select from the KSampler node.
* Only used when prepareGraph is not provided.
* Mutually exclusive with prepareGraph.
*/
export async function setupBuilder(
comfyPage: ComfyPage,
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
): Promise<NodeReference> {
prepareGraph?: (ksampler: NodeReference) => Promise<BuilderSetupResult>,
widgetNames?: string[]
): Promise<void> {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
const { inputNodeTitle, widgetNames: inputWidgets } = prepareGraph
? await prepareGraph(ksampler)
: { inputNodeTitle: 'KSampler', widgetNames: widgetNames ?? ['seed'] }
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
await appMode.select.selectInputWidget(inputNode)
for (const name of inputWidgets) {
await appMode.select.selectInputWidget(inputNodeTitle, name)
}
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
return inputNode
await appMode.select.selectOutputNode('Save Image')
}
/**
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
*
* Returns the subgraph node reference for further interaction.
*/
export async function setupSubgraphBuilder(
comfyPage: ComfyPage
): Promise<NodeReference> {
return setupBuilder(comfyPage, async (ksampler) => {
): Promise<void> {
await setupBuilder(comfyPage, async (ksampler) => {
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
expect(promotedNames).toContain('seed')
return subgraphNode
return {
inputNodeTitle: 'New Subgraph',
widgetNames: ['seed']
}
})
}
/**
* Open the save-as dialog, fill name + view type, click save,
* and wait for the success dialog.
*/
export async function builderSaveAs(
appMode: AppModeHelper,
workflowName: string,
viewType: 'App' | 'Node graph' = 'App'
) {
await appMode.footer.saveAsButton.click()
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
await appMode.saveAs.fillAndSave(workflowName, viewType)
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({
timeout: 5000
})
}
/**
* Load a different workflow, then reopen the named one from the sidebar.
* Caller must ensure the page is in graph mode (not builder or app mode)
* before calling.
*/
export async function openWorkflowFromSidebar(
comfyPage: ComfyPage,
name: string
) {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(name).dblclick()
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toContain(name)
}
/** Save the workflow, reopen it, and enter app mode. */
export async function saveAndReopenInAppMode(
comfyPage: ComfyPage,

View File

@@ -0,0 +1,19 @@
import type { Page } from '@playwright/test'
export async function interceptClipboardWrite(page: Page) {
await page.evaluate(() => {
const w = window as Window & { __copiedText?: string }
w.__copiedText = ''
navigator.clipboard.writeText = async (text: string) => {
w.__copiedText = text
}
})
}
export async function getClipboardText(page: Page): Promise<string> {
return (
(await page.evaluate(
() => (window as Window & { __copiedText?: string }).__copiedText
)) ?? ''
)
}

View File

@@ -18,12 +18,9 @@ export class ComfyTemplates {
}
async expectMinimumCardCount(count: number) {
await expect(async () => {
const cardCount = await this.allTemplateCards.count()
expect(cardCount).toBeGreaterThanOrEqual(count)
}).toPass({
timeout: 1_000
})
await expect
.poll(() => this.allTemplateCards.count())
.toBeGreaterThanOrEqual(count)
}
async loadTemplate(id: string) {

View File

@@ -60,13 +60,7 @@ async function addNode(page: Page, nodeType: string): Promise<string> {
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enableLinearMode()
})
test('Select dropdown is not clipped in app mode panel', async ({

View File

@@ -9,13 +9,7 @@ import {
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true

View File

@@ -0,0 +1,94 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/** One representative of each widget type from the default workflow. */
type WidgetType = 'textarea' | 'number' | 'select' | 'text'
const WIDGET_TEST_DATA: {
nodeId: string
widgetName: string
type: WidgetType
fill: string
expected: unknown
}[] = [
{
nodeId: '6',
widgetName: 'text',
type: 'textarea',
fill: 'test prompt',
expected: 'test prompt'
},
{
nodeId: '5',
widgetName: 'width',
type: 'number',
fill: '768',
expected: 768
},
{
nodeId: '3',
widgetName: 'cfg',
type: 'number',
fill: '3.5',
expected: 3.5
},
{
nodeId: '3',
widgetName: 'sampler_name',
type: 'select',
fill: 'uni_pc',
expected: 'uni_pc'
},
{
nodeId: '9',
widgetName: 'filename_prefix',
type: 'text',
fill: 'test_prefix',
expected: 'test_prefix'
}
]
test.describe('App mode widget values in prompt', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Widget values are sent correctly in prompt POST', async ({
comfyPage
}) => {
const { appMode } = comfyPage
const inputs: [string, string][] = WIDGET_TEST_DATA.map(
({ nodeId, widgetName }) => [nodeId, widgetName]
)
await appMode.enterAppModeWithInputs(inputs)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
for (const { nodeId, widgetName, type, fill } of WIDGET_TEST_DATA) {
const key = `${nodeId}:${widgetName}`
switch (type) {
case 'textarea':
await appMode.widgets.fillTextarea(key, fill)
break
case 'number':
await appMode.widgets.fillNumber(key, fill)
break
case 'select':
await appMode.widgets.selectOption(key, fill)
break
case 'text':
await appMode.widgets.fillText(key, fill)
break
default:
throw new Error(`Unknown widget type: ${type satisfies never}`)
}
}
const prompt = await appMode.widgets.runAndCapturePrompt()
for (const { nodeId, widgetName, expected } of WIDGET_TEST_DATA) {
expect(prompt[nodeId].inputs[widgetName]).toBe(expected)
}
})
})

View File

@@ -0,0 +1,382 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
withInputFiles,
withOutputAssets,
withAsset,
withPagination,
withUploadResponse
} from '../fixtures/helpers/AssetHelper'
import {
STABLE_CHECKPOINT,
STABLE_LORA,
STABLE_INPUT_IMAGE,
STABLE_OUTPUT
} from '../fixtures/data/assetFixtures'
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
comfyPage
}) => {
const helper = createAssetHelper(
comfyPage.page,
withModels(3, 'checkpoints')
)
expect(helper.assetCount).toBe(3)
expect(
helper.getAssets().every((a) => a.tags?.includes('checkpoints'))
).toBe(true)
})
test('composes multiple operators', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withModels(2, 'checkpoints'),
withInputFiles(2),
withOutputAssets(1)
)
expect(helper.assetCount).toBe(5)
})
test('adds individual assets via withAsset', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA)
)
expect(helper.assetCount).toBe(2)
expect(helper.getAsset(STABLE_CHECKPOINT.id)).toMatchObject({
id: STABLE_CHECKPOINT.id,
name: STABLE_CHECKPOINT.name
})
})
test('withPagination sets pagination options', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withModels(2),
withPagination({ total: 100, hasMore: true })
)
expect(helper.assetCount).toBe(2)
})
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
)
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets`
)
expect(status).toBe(200)
const data = body as {
assets: unknown[]
total: number
has_more: boolean
}
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(2)
expect(data.has_more).toBe(false)
await assetApi.clearMocks()
})
test('GET /assets respects pagination params', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?limit=2&offset=0`
)
const data = body as {
assets: unknown[]
total: number
has_more: boolean
}
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(10)
expect(data.has_more).toBe(true)
await assetApi.clearMocks()
})
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
withAsset(STABLE_INPUT_IMAGE)
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=models,checkpoints`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(1)
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
const found = await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`
)
expect(found.status).toBe(200)
const asset = found.body as { id: string }
expect(asset.id).toBe(STABLE_CHECKPOINT.id)
const notFound = await assetApi.fetch(
`${comfyPage.url}/api/assets/nonexistent-id`
)
expect(notFound.status).toBe(404)
await assetApi.clearMocks()
})
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'renamed.safetensors' })
}
)
expect(status).toBe(200)
const updated = body as { name: string }
expect(updated.name).toBe('renamed.safetensors')
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
const { status } = await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{ method: 'DELETE' }
)
expect(status).toBe(204)
expect(assetApi.assetCount).toBe(1)
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
await assetApi.clearMocks()
})
test('POST /assets returns upload response', async ({ comfyPage }) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
tags: ['models'],
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets`,
{ method: 'POST' }
)
expect(status).toBe(201)
const data = body as { id: string; name: string }
expect(data.id).toBe('custom-upload-001')
expect(data.name).toBe('custom.safetensors')
await assetApi.clearMocks()
})
test('POST /assets/download returns async download response', async ({
comfyPage
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets/download`,
{ method: 'POST' }
)
expect(status).toBe(202)
const data = body as { task_id: string; status: string }
expect(data.task_id).toBe('download-task-001')
expect(data.status).toBe('created')
await assetApi.clearMocks()
})
})
test.describe('mutation tracking', () => {
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
await assetApi.fetch(`${comfyPage.url}/api/assets`, { method: 'POST' })
await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'updated.safetensors' })
}
)
await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{ method: 'DELETE' }
)
const mutations = assetApi.getMutations()
expect(mutations).toHaveLength(3)
expect(mutations[0].method).toBe('POST')
expect(mutations[1].method).toBe('PUT')
expect(mutations[2].method).toBe('DELETE')
await assetApi.clearMocks()
})
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
await assetApi.fetch(`${comfyPage.url}/api/assets`)
await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets`
)
expect(status).toBe(503)
const data = body as { error: string }
expect(data.error).toBe('Service Unavailable')
await assetApi.clearMocks()
})
})
test.describe('clearMocks', () => {
test('resets store, mutations, and unroutes handlers', async ({
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
await assetApi.fetch(`${comfyPage.url}/api/assets`, { method: 'POST' })
expect(assetApi.getMutations()).toHaveLength(1)
expect(assetApi.assetCount).toBe(1)
await assetApi.clearMocks()
expect(assetApi.getMutations()).toHaveLength(0)
expect(assetApi.assetCount).toBe(0)
})
})
test.describe('fixture generators', () => {
test('generateModels produces deterministic assets', async ({
comfyPage
}) => {
const helper = createAssetHelper(comfyPage.page, withModels(3, 'loras'))
const assets = helper.getAssets()
expect(assets).toHaveLength(3)
expect(assets.every((a) => a.tags?.includes('loras'))).toBe(true)
expect(assets.every((a) => a.tags?.includes('models'))).toBe(true)
const ids = assets.map((a) => a.id)
expect(new Set(ids).size).toBe(3)
})
test('generateInputFiles produces deterministic input assets', async ({
comfyPage
}) => {
const helper = createAssetHelper(comfyPage.page, withInputFiles(3))
const assets = helper.getAssets()
expect(assets).toHaveLength(3)
expect(assets.every((a) => a.tags?.includes('input'))).toBe(true)
})
test('generateOutputAssets produces deterministic output assets', async ({
comfyPage
}) => {
const helper = createAssetHelper(comfyPage.page, withOutputAssets(5))
const assets = helper.getAssets()
expect(assets).toHaveLength(5)
expect(assets.every((a) => a.tags?.includes('output'))).toBe(true)
expect(assets.every((a) => a.name.startsWith('ComfyUI_'))).toBe(true)
})
test('stable fixtures have expected properties', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
withAsset(STABLE_INPUT_IMAGE),
withAsset(STABLE_OUTPUT)
)
const checkpoint = helper.getAsset(STABLE_CHECKPOINT.id)!
expect(checkpoint.tags).toContain('checkpoints')
expect(checkpoint.size).toBeGreaterThan(0)
expect(checkpoint.created_at).toBeTruthy()
const lora = helper.getAsset(STABLE_LORA.id)!
expect(lora.tags).toContain('loras')
const input = helper.getAsset(STABLE_INPUT_IMAGE.id)!
expect(input.tags).toContain('input')
const output = helper.getAsset(STABLE_OUTPUT.id)!
expect(output.tags).toContain('output')
})
})
})

View File

@@ -0,0 +1,157 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '../helpers/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
async function saveCloseAndReopenAsApp(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await appMode.toggleAppMode()
}
test.describe('Builder input reordering', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Drag first input to last position', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
await appMode.select.dragInputItem(0, 2)
await expect(appMode.select.inputItemTitles).toHaveText([
'steps',
'cfg',
'seed'
])
await appMode.steps.goToPreview()
await expect(appMode.select.previewWidgetLabels).toHaveText([
'steps',
'cfg',
'seed'
])
})
test('Drag last input to first position', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
await appMode.select.dragInputItem(2, 0)
await expect(appMode.select.inputItemTitles).toHaveText([
'cfg',
'seed',
'steps'
])
await appMode.steps.goToPreview()
await expect(appMode.select.previewWidgetLabels).toHaveText([
'cfg',
'seed',
'steps'
])
})
test('Drag input to middle position', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
await appMode.select.dragInputItem(0, 1)
await expect(appMode.select.inputItemTitles).toHaveText([
'steps',
'seed',
'cfg'
])
await appMode.steps.goToPreview()
await expect(appMode.select.previewWidgetLabels).toHaveText([
'steps',
'seed',
'cfg'
])
})
test('Reorder in preview step reflects in app mode after save', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToPreview()
await expect(appMode.select.previewWidgetLabels).toHaveText(WIDGETS)
await appMode.select.dragPreviewItem(0, 2)
await expect(appMode.select.previewWidgetLabels).toHaveText([
'steps',
'cfg',
'seed'
])
const workflowName = `${Date.now()} reorder-preview`
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.select.previewWidgetLabels).toHaveText([
'steps',
'cfg',
'seed'
])
})
test('Reorder inputs persists after save and reload', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await appMode.select.dragInputItem(0, 2)
await expect(appMode.select.inputItemTitles).toHaveText([
'steps',
'cfg',
'seed'
])
const workflowName = `${Date.now()} reorder-persist`
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.select.previewWidgetLabels).toHaveText([
'steps',
'cfg',
'seed'
])
})
})

View File

@@ -2,45 +2,14 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
import { setupBuilder } from '../helpers/builderTestUtils'
import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '../helpers/builderTestUtils'
import { fitToViewInstant } from '../helpers/fitToView'
/**
* Open the save-as dialog, fill name + view type, click save,
* and wait for the success dialog.
*/
async function builderSaveAs(
appMode: AppModeHelper,
workflowName: string,
viewType: 'App' | 'Node graph'
) {
await appMode.footer.saveAsButton.click()
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
await appMode.saveAs.fillAndSave(workflowName, viewType)
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
}
/**
* Load a different workflow, then reopen the named one from the sidebar.
* Caller must ensure the page is in graph mode (not builder or app mode)
* before calling.
*/
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(name).dblclick()
await comfyPage.nextFrame()
await expect(async () => {
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toContain(name)
}).toPass({ timeout: 5000 })
}
/**
* After a first save, open save-as again from the chevron,
* fill name + view type, and save.
@@ -57,13 +26,7 @@ async function reSaveAs(
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
@@ -203,10 +166,9 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
// Select I/O to enable the button
await appMode.steps.goToInputs()
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await appMode.select.selectInputWidget(ksampler)
await appMode.select.selectInputWidget('KSampler', 'seed')
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
await appMode.select.selectOutputNode('Save Image')
// State 2: Enabled "Save as" (unsaved, has outputs)
const enabledBox = await appMode.footer.saveAsButton.boundingBox()

View File

@@ -27,41 +27,51 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
test('Can undo multiple operations', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.toast.getToastErrorCount()).toBe(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(2)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(2)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(1)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(1)
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(2)
})
})
@@ -154,7 +164,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.graph!.extra.foo = 'bar'
})
@@ -164,7 +174,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})

View File

@@ -38,8 +38,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await comfyPage.clipboard.copy(null)
await comfyPage.clipboard.paste(null)
await comfyPage.clipboard.paste(null)
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
await expect
.poll(() => textBox.inputValue())
.toBe(originalString + originalString)
})
test('Can copy and paste widget value', async ({ comfyPage }) => {
@@ -114,20 +115,24 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
test('Can undo paste multiple nodes as single action', async ({
comfyPage
}) => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(1)
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
await comfyPage.canvas.click()
await comfyPage.keyboard.selectAll()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
const pasteCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(pasteCount).toBe(initialCount * 2)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount * 2)
await comfyPage.keyboard.undo()
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(undoCount).toBe(initialCount)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
test(
@@ -135,7 +140,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
{ tag: ['@node'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
// Step 1: Copy a KSampler node with Ctrl+C and paste with Ctrl+V
const ksamplerNodes =
@@ -174,7 +179,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
{ timeout: 5_000 }
)
.toContain('image32x32')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
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 } })

View File

@@ -3,261 +3,11 @@ import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/platform/keybindings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show error overlay when loading a workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing.*installed/i)
})
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing.*installed/i)
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
// Expand the pack group row to reveal node type names
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should show MissingNodeCard in errors tab when clicking See Errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
})
})
test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
// Dismiss the error overlay
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(errorOverlay).not.toBeVisible()
// Make a change to the graph by moving a node
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
// Undo and redo should not resurface the error overlay
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
test.describe('Execution error', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
test('Should display an error message when an execution error occurs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
// Wait for the error overlay to be visible
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
})
})
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
// Wait for error overlay and click "See Errors"
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
await expect(copyButton).toBeVisible()
})
})
test.describe('Missing models in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
})
test('Should show error overlay with missing models when workflow has missing models', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/required model.*missing/i)
})
test('Should show missing models from node properties', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_from_node_properties'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/required model.*missing/i)
})
test('Should not show missing models when widget values have changed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/model_metadata_widget_mismatch'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
).not.toBeVisible()
})
})
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
@@ -379,38 +129,6 @@ test.describe('Support', () => {
})
})
test.describe('Error dialog', () => {
test('Should display an error dialog when graph configure fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const graph = window.graph!
;(graph as { configure: () => void }).configure = () => {
throw new Error('Error on configure!')
}
})
await comfyPage.workflow.loadWorkflow('default')
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
})
test('Should display an error dialog when prompt execution fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(async () => {
const app = window.app!
app.api.queuePrompt = () => {
throw new Error('Error on queuePrompt!')
}
await app.queuePrompt(0)
})
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
})
})
test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage

View File

@@ -83,7 +83,7 @@ test.describe('Sign In dialog', { tag: '@ui' }, () => {
})
test('Should close dialog via close button', async () => {
await dialog.close()
await dialog.closeButton.click()
await expect(dialog.root).toBeHidden()
})

View File

@@ -0,0 +1,139 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '../helpers/clipboardSpy'
async function triggerConfigureError(
comfyPage: ComfyPage,
message = 'Error on configure!'
) {
await comfyPage.page.evaluate((msg: string) => {
const graph = window.graph!
;(graph as { configure: () => void }).configure = () => {
throw new Error(msg)
}
}, message)
await comfyPage.workflow.loadWorkflow('default')
return comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
}
async function waitForPopupNavigation(page: Page, action: () => Promise<void>) {
const popupPromise = page.waitForEvent('popup')
await action()
const popup = await popupPromise
await popup.waitForLoadState()
return popup
}
test.describe('Error dialog', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Should display an error dialog when graph configure fails', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
})
test('Should display an error dialog when prompt execution fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(async () => {
const app = window.app!
app.api.queuePrompt = () => {
throw new Error('Error on queuePrompt!')
}
await app.queuePrompt(0)
})
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
await expect(errorDialog).toBeVisible()
})
test('Should display error message body', async ({ comfyPage }) => {
const errorDialog = await triggerConfigureError(
comfyPage,
'Test error message body'
)
await expect(errorDialog).toBeVisible()
await expect(errorDialog).toContainText('Test error message body')
})
test('Should show report section when "Show Report" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
await expect(errorDialog.locator('pre')).not.toBeVisible()
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
const reportPre = errorDialog.locator('pre')
await expect(reportPre).toBeVisible()
await expect(reportPre).toHaveText(/\S/)
await expect(
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
).not.toBeVisible()
})
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
await expect(errorDialog.locator('pre')).toBeVisible()
await interceptClipboardWrite(comfyPage.page)
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
const reportText = await errorDialog.locator('pre').textContent()
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toBe(reportText)
})
test('Should open GitHub issues search when "Find Issues" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
const popup = await waitForPopupNavigation(comfyPage.page, () =>
errorDialog.getByTestId(TestIds.dialogs.errorDialogFindIssues).click()
)
const url = new URL(popup.url())
expect(url.hostname).toBe('github.com')
expect(url.pathname).toContain('/issues')
await popup.close()
})
test('Should open contact support when "Help Fix This" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
const popup = await waitForPopupNavigation(comfyPage.page, () =>
errorDialog.getByTestId(TestIds.dialogs.errorDialogContactSupport).click()
)
const url = new URL(popup.url())
expect(url.hostname).toBe('support.comfy.org')
await popup.close()
})
})

View File

@@ -0,0 +1,195 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Error overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
function getOverlay(page: Page) {
return page.getByTestId(TestIds.dialogs.errorOverlay)
}
function getSeeErrorsButton(page: Page) {
return getOverlay(page).getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
}
test.describe('Labels', () => {
test('Should display singular error count label for single error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getOverlay(comfyPage.page)).toContainText(/1 ERROR/i)
})
test('Should display "Show missing nodes" button for missing node errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing nodes/i
)
})
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing models/i
)
})
test('Should display "Show missing inputs" button for missing media errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing inputs/i
)
})
test('Should display generic "See Errors" button for multiple error types', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_and_media')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/See Errors/i
)
})
})
test.describe('Persistence', () => {
test('Does not resurface missing nodes on undo/redo', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = getOverlay(comfyPage.page)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).not.toBeVisible()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
})
test.describe('See Errors flow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error overlay appears on execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(getOverlay(comfyPage.page)).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
})
})
})

View File

@@ -1,93 +0,0 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error overlay appears on execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
})
})

View File

@@ -0,0 +1,40 @@
import type { Locator } from '@playwright/test'
export class Load3DHelper {
constructor(readonly node: Locator) {}
get canvas(): Locator {
return this.node.locator('canvas')
}
get menuButton(): Locator {
return this.node.getByRole('button', { name: /show menu/i })
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: /start recording/i })
}
get colorInput(): Locator {
return this.node.locator('input[type="color"]')
}
getUploadButton(label: string): Locator {
return this.node.getByText(label)
}
getMenuCategory(name: string): Locator {
return this.node.getByText(name, { exact: true })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async setBackgroundColor(hex: string): Promise<void> {
await this.colorInput.evaluate((el, value) => {
;(el as HTMLInputElement).value = value
el.dispatchEvent(new Event('input', { bubbles: true }))
}, hex)
}
}

View File

@@ -0,0 +1,97 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { Load3DHelper } from './Load3DHelper'
test.describe('Load3D', () => {
let load3d: Load3DHelper
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeLocator('1')
load3d = new Load3DHelper(node)
})
test(
'Renders canvas with upload buttons and controls menu',
{ tag: ['@smoke', '@screenshot'] },
async () => {
await expect(load3d.node).toBeVisible()
await expect(load3d.canvas).toBeVisible()
const canvasBox = await load3d.canvas.boundingBox()
expect(canvasBox!.width).toBeGreaterThan(0)
expect(canvasBox!.height).toBeGreaterThan(0)
await expect(load3d.getUploadButton('upload 3d model')).toBeVisible()
await expect(
load3d.getUploadButton('upload extra resources')
).toBeVisible()
await expect(load3d.getUploadButton('clear')).toBeVisible()
await expect(load3d.menuButton).toBeVisible()
await expect(load3d.node).toHaveScreenshot('load3d-empty-node.png', {
maxDiffPixelRatio: 0.05
})
}
)
test(
'Controls menu opens and shows all categories',
{ tag: ['@smoke', '@screenshot'] },
async () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
{ maxDiffPixelRatio: 0.05 }
)
}
)
test(
'Changing background color updates the scene',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
await load3d.setBackgroundColor('#cc3333')
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const config = n?.properties?.['Scene Config'] as
| Record<string, string>
| undefined
return config?.backgroundColor
}),
{ timeout: 3000 }
)
.toBe('#cc3333')
await expect(load3d.node).toHaveScreenshot('load3d-red-background.png', {
maxDiffPixelRatio: 0.05
})
}
)
test(
'Recording controls are visible for Load3D',
{ tag: '@smoke' },
async () => {
await expect(load3d.recordingButton).toBeVisible()
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -48,10 +48,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
await comfyPage.nextFrame()
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow'
])
await expect
.poll(() => comfyPage.menu.topbar.getTabNames())
.toEqual(['Unsaved Workflow'])
})
})

View File

@@ -4,6 +4,7 @@ import {
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import type { WorkspaceStore } from '../types/globals'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
@@ -23,6 +24,67 @@ async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
}
async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
await expect(comfyPage.selectionToolbox).toBeVisible()
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
await expect(helpButton).toBeVisible()
await helpButton.click({ force: true })
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')
}
async function setLocaleAndWaitForWorkflowReload(
comfyPage: ComfyPage,
locale: string
) {
await comfyPage.page.evaluate(async (targetLocale) => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) {
throw new Error('No active workflow while waiting for locale reload')
}
const changeTracker = workflow.changeTracker.constructor as unknown as {
isLoadingGraph: boolean
}
let sawLoading = false
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
const tick = () => {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}
if (sawLoading && !changeTracker.isLoadingGraph) {
resolve()
return
}
if (performance.now() > timeoutAt) {
reject(
new Error(
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
)
)
return
}
requestAnimationFrame(tick)
}
tick()
})
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
await waitForReload
}, locale)
}
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
@@ -46,20 +108,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
// Select the node with panning to ensure toolbox is visible
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Wait for selection toolbox to appear
await expect(comfyPage.selectionToolbox).toBeVisible()
// Click the help button in the selection toolbox
const helpButton = comfyPage.selectionToolbox.locator(
'button[data-testid="info-button"]'
)
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the help page is shown for the correct node
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
@@ -168,16 +218,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
// Verify loading spinner is shown
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
// Wait for content to load
@@ -201,16 +243,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
// Verify fallback content is shown (description, inputs, outputs)
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('Description')
await expect(helpPage).toContainText('Inputs')
await expect(helpPage).toContainText('Outputs')
@@ -239,14 +273,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSampler Documentation')
// Check that relative image paths are prefixed correctly
@@ -290,14 +317,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
// Check relative video paths are prefixed
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
@@ -364,15 +384,9 @@ This is documentation for a custom node.
await selectNodeWithPan(comfyPage, firstNode)
}
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
if (await helpButton.isVisible()) {
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('Custom Node Documentation')
// Check image path for custom nodes
@@ -408,14 +422,7 @@ This is documentation for a custom node.
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
// Dangerous elements should be removed
await expect(helpPage.locator('script')).toHaveCount(0)
@@ -471,27 +478,20 @@ This is English documentation.
})
// Set locale to Japanese
await comfyPage.settings.setSetting('Comfy.Locale', 'ja')
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
try {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.waitFor({ state: 'visible', timeout: 10_000 })
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
// Reset locale
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
} finally {
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
}
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {
@@ -505,14 +505,7 @@ This is English documentation.
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
@@ -552,14 +545,7 @@ This is English documentation.
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSampler Help')
await expect(helpPage).toContainText('This is KSampler documentation')

View File

@@ -4,6 +4,16 @@ import {
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
async function waitForSearchInsertion(
comfyPage: ComfyPage,
initialNodeCount: number
) {
await expect(comfyPage.searchBox.input).toHaveCount(0)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount + 1)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
@@ -60,18 +70,22 @@ test.describe('Node search box', { tag: '@node' }, () => {
})
test('Can add node', { tag: '@screenshot' }, async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
})
test('Can auto link node', { tag: '@screenshot' }, async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode', {
suggestionIndex: 0
})
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
})
@@ -81,6 +95,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)
const checkpointNode = await comfyPage.nodeOps.getNodeRefById(4)
@@ -98,6 +113,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
suggestionIndex: 0
})
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot(
'auto-linked-node-batch.png'
)
@@ -108,12 +124,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Link release connecting to node with no slots',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.locator('.p-chip-remove-icon').click()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler', {
exact: true
})
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot(
'added-node-no-connection.png'
)
@@ -296,11 +314,13 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'Can search and add node from context menu',
{ tag: '@screenshot' },
async ({ comfyPage, comfyMouse }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.contextMenu.clickMenuItem('Search')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)

View File

@@ -0,0 +1,17 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
export async function openErrorsTabViaSeeErrors(
comfyPage: ComfyPage,
workflow: string
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}

View File

@@ -1,31 +1,73 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Errors tab', () => {
let panel: PropertiesPanelHelper
test.describe('Errors tab - common', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
await expect(panel.errorsTabIcon).toBeVisible()
})
test('should not show Errors tab when errors are disabled', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.errorsTabIcon).not.toBeVisible()
test.describe('Tab visibility', () => {
test('Should show Errors tab when errors exist', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(panel.errorsTabIcon).toBeVisible()
})
test('Should not show Errors tab when setting is disabled', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
false
)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(panel.errorsTabIcon).not.toBeVisible()
})
})
test.describe('Search and filter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
test('Should filter execution errors by search query', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
const runtimePanel = comfyPage.page.getByTestId(
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('nonexistent_query_xyz_12345')
await expect(runtimePanel).not.toBeVisible()
})
})
})

View File

@@ -0,0 +1,57 @@
import type { ComfyPage } from '../../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
async function openExecutionErrorTab(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
}
test('Should show Find on GitHub and Copy buttons in error card', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorCardFindOnGithub)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
).toBeVisible()
})
test('Should show error message in runtime error panel', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
const runtimePanel = comfyPage.page.getByTestId(
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toContainText(/\S/)
})
})

View File

@@ -1,21 +1,9 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function loadMissingMediaAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow = 'missing/missing_media_single'
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -48,7 +36,7 @@ function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
test.describe('Missing media inputs in Error Tab', () => {
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
@@ -58,27 +46,8 @@ test.describe('Missing media inputs in Error Tab', () => {
})
test.describe('Detection', () => {
test('Shows error overlay when workflow has missing media inputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing required inputs/i)
})
test('Shows missing media group in errors tab after clicking See Errors', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
@@ -88,7 +57,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_media_multiple'
)
@@ -99,30 +68,20 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
).toBeVisible()
})
test('Does not show error overlay when all media inputs exist', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
})
})
test.describe('Upload flow (2-step confirm)', () => {
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -132,11 +91,11 @@ test.describe('Missing media inputs in Error Tab', () => {
})
})
test.describe('Library select flow (2-step confirm)', () => {
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
@@ -162,7 +121,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -181,7 +140,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
@@ -195,7 +154,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '../../helpers/clipboardSpy'
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
})
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeVisible()
})
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
})
test('Should expand model row to show referencing nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_models_with_nodes'
)
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
await expect(locateButton.first()).not.toBeVisible()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expandButton.first().click()
await expect(locateButton.first()).toBeVisible()
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().click()
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
test('Should show Copy URL button for non-asset models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
})
test('Should show Download button for downloadable models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const downloadButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
})
})
})

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
})
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).not.toBeVisible()
})
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
})
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
})
})

View File

@@ -16,13 +16,21 @@ test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Search for and add the RecordAudio node
await comfyPage.searchBox.fillAndSelectFirstNode('RecordAudio')
await comfyPage.searchBox.fillAndSelectFirstNode('Record Audio', {
exact: true
})
await comfyPage.nextFrame()
// Verify the RecordAudio node was added
const recordAudioNodes =
await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')
expect(recordAudioNodes.length).toBe(1)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')).length,
{
timeout: 5000
}
)
.toBe(1)
// Take a screenshot of the canvas with the RecordAudio node
await expect(comfyPage.canvas).toHaveScreenshot('record_audio_node.png')

View File

@@ -113,21 +113,48 @@ test.describe(
'reroute/single-native-reroute-default-workflow'
)
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
// since the link is a bezier curve and not a straight line.
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
const checkpointNode = await comfyPage.nodeOps.getNodeRefById(4)
const positiveClipNode = await comfyPage.nodeOps.getNodeRefById(6)
const negativeClipNode = await comfyPage.nodeOps.getNodeRefById(7)
const checkpointClipOutput = await checkpointNode.getOutput(1)
const positiveClipInput = await positiveClipNode.getInput(0)
const negativeClipInput = await negativeClipNode.getInput(0)
// Dynamically read the rendered link marker position from the canvas,
// targeting link 5 (CLIP from CheckpointLoaderSimple to negative CLIPTextEncode).
const middlePoint = await comfyPage.page.waitForFunction(() => {
const canvas = window['app']?.canvas
if (!canvas?.renderedPaths) return null
for (const segment of canvas.renderedPaths) {
if (segment.id === 5 && segment._pos) {
return { x: segment._pos[0], y: segment._pos[1] }
}
}
return null
})
const pos = await middlePoint.jsonValue()
if (!pos) throw new Error('Rendered midpoint for link 5 was not found')
// Click the middle point of the link to open the context menu.
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
await comfyPage.page.mouse.click(pos.x, pos.y)
// Click the "Delete" context menu option.
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_delete_from_midpoint_context_menu.png'
)
await expect
.poll(async () => ({
checkpointClipOutputLinks: await checkpointClipOutput.getLinkCount(),
positiveClipInputLinks: await positiveClipInput.getLinkCount(),
negativeClipInputLinks: await negativeClipInput.getLinkCount()
}))
.toEqual({
checkpointClipOutputLinks: 1,
positiveClipInputLinks: 1,
negativeClipInputLinks: 0
})
})
}
)

View File

@@ -229,6 +229,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await cloneItem.click()
await expect(cloneItem).toHaveCount(0)
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(nodeCount + 1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(nodeCount + 1)
})
})

View File

@@ -19,13 +19,17 @@ test.describe('@canvas Selection Rectangle', () => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBe(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBeGreaterThan(0)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBeGreaterThan(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
await comfyPage.page
@@ -37,26 +41,26 @@ test.describe('@canvas Selection Rectangle', () => {
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
test('Single click selects one node', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: ['Control']
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
})
test('Selected nodes have visual indicator', async ({ comfyPage }) => {
@@ -71,7 +75,7 @@ test.describe('@canvas Selection Rectangle', () => {
test('Drag-select rectangle selects multiple nodes', async ({
comfyPage
}) => {
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
@@ -79,7 +83,9 @@ test.describe('@canvas Selection Rectangle', () => {
await comfyPage.nextFrame()
const totalCount = await comfyPage.vueNodes.getNodeCount()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBe(totalCount)
expect(totalCount).toBeGreaterThan(1)
})
})

View File

@@ -194,6 +194,29 @@ test.describe('Assets sidebar - grid view display', () => {
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
test('Displays svg outputs', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job-alpha',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'logo.svg',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
})
])
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.assetCards.locator('.pi-image')).toBeVisible()
})
})
// ==========================================================================

View File

@@ -1,16 +1,52 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
const bookmarksSettingId = 'Comfy.NodeLibrary.Bookmarks.V2'
const bookmarksCustomizationSettingId =
'Comfy.NodeLibrary.BookmarksCustomization'
type BookmarkCustomizationMap = Record<
string,
{
icon?: string
color?: string
}
>
async function expectBookmarks(comfyPage: ComfyPage, bookmarks: string[]) {
await expect
.poll(() => comfyPage.settings.getSetting<string[]>(bookmarksSettingId))
.toEqual(bookmarks)
}
async function expectBookmarkCustomization(
comfyPage: ComfyPage,
customization: BookmarkCustomizationMap
) {
await expect
.poll(() =>
comfyPage.settings.getSetting<BookmarkCustomizationMap>(
bookmarksCustomizationSettingId
)
)
.toEqual(customization)
}
async function renameInlineFolder(comfyPage: ComfyPage, newName: string) {
const renameInput = comfyPage.page.locator('.editable-text input')
await expect(renameInput).toBeVisible()
await renameInput.fill(newName)
await renameInput.press('Enter')
}
test.describe('Node library sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',
{}
)
await comfyPage.settings.setSetting(bookmarksSettingId, [])
await comfyPage.settings.setSetting(bookmarksCustomizationSettingId, {})
// Open the sidebar
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
@@ -21,14 +57,11 @@ test.describe('Node library sidebar', () => {
await tab.getFolder('sampling').click()
// Hover over a node to display the preview
const nodeSelector = '.p-tree-node-leaf'
const nodeSelector = tab.nodeSelector('KSampler (Advanced)')
await comfyPage.page.hover(nodeSelector)
// Verify the preview is displayed
const previewVisible = await comfyPage.page.isVisible(
'.node-lib-node-preview'
)
expect(previewVisible).toBe(true)
await expect(tab.nodePreview).toBeVisible()
const count = await comfyPage.nodeOps.getGraphNodesCount()
// Drag the node onto the canvas
@@ -48,9 +81,12 @@ test.describe('Node library sidebar', () => {
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector, {
targetPosition
})
await comfyPage.nextFrame()
// Verify the node is added to the canvas
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(count + 1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(count + 1)
})
test('Bookmark node', async ({ comfyPage }) => {
@@ -61,33 +97,29 @@ test.describe('Node library sidebar', () => {
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
// Verify the bookmark is added to the bookmarks tab
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['KSamplerAdvanced'])
await expectBookmarks(comfyPage, ['KSamplerAdvanced'])
// Verify the bookmark node with the same name is added to the tree.
expect(await tab.getNode('KSampler (Advanced)').count()).toBe(2)
await expect(tab.getNode('KSampler (Advanced)')).toHaveCount(2)
// Hover on the bookmark node to display the preview
await comfyPage.page.hover('.node-lib-bookmark-tree-explorer .tree-leaf')
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(true)
await expect(tab.nodePreview).toBeVisible()
})
test('Ignores unrecognized node', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo'])
await expectBookmarks(comfyPage, ['foo'])
await comfyPage.nextFrame()
const tab = comfyPage.menu.nodeLibraryTab
expect(await tab.getFolder('sampling').count()).toBe(1)
expect(await tab.getNode('foo').count()).toBe(0)
await expect(tab.getFolder('sampling')).toHaveCount(1)
await expect(tab.getNode('foo')).toHaveCount(0)
})
test('Displays empty bookmarks folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
expect(await tab.getFolder('foo').count()).toBe(1)
await expect(tab.getFolder('foo')).toHaveCount(1)
})
test('Can add new bookmark folder', async ({ comfyPage }) => {
@@ -97,17 +129,14 @@ test.describe('Node library sidebar', () => {
await textInput.waitFor({ state: 'visible' })
await textInput.fill('New Folder')
await textInput.press('Enter')
expect(await tab.getFolder('New Folder').count()).toBe(1)
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['New Folder/'])
await expect(tab.getFolder('New Folder')).toHaveCount(1)
await expectBookmarks(comfyPage, ['New Folder/'])
})
test('Can add nested bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByRole('menuitem', { name: 'New Folder' }).click()
@@ -116,59 +145,47 @@ test.describe('Node library sidebar', () => {
await textInput.fill('bar')
await textInput.press('Enter')
expect(await tab.getFolder('bar').count()).toBe(1)
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['foo/', 'foo/bar/'])
await expect(tab.getFolder('bar')).toHaveCount(1)
await expectBookmarks(comfyPage, ['foo/', 'foo/bar/'])
})
test('Can delete bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Delete').click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
await expectBookmarks(comfyPage, [])
})
test('Can rename bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu-item-label:has-text("Rename")')
.click()
await comfyPage.page.keyboard.insertText('bar')
await comfyPage.page.keyboard.press('Enter')
await renameInlineFolder(comfyPage, 'bar')
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
await expectBookmarks(comfyPage, ['bar/'])
})
test('Can add bookmark by dragging node to bookmark folder', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('sampling').click()
await comfyPage.page.dragAndDrop(
tab.nodeSelector('KSampler (Advanced)'),
tab.folderSelector('foo')
)
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['foo/', 'foo/KSamplerAdvanced'])
await expectBookmarks(comfyPage, ['foo/', 'foo/KSamplerAdvanced'])
})
test('Can add bookmark by clicking bookmark button', async ({
@@ -177,41 +194,36 @@ test.describe('Node library sidebar', () => {
const tab = comfyPage.menu.nodeLibraryTab
await tab.getFolder('sampling').click()
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['KSamplerAdvanced'])
await expectBookmarks(comfyPage, ['KSamplerAdvanced'])
})
test('Can unbookmark node (Top level bookmark)', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
await comfyPage.settings.setSetting(bookmarksSettingId, [
'KSamplerAdvanced'
])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getNode('KSampler (Advanced)')).toHaveCount(1)
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
await expectBookmarks(comfyPage, [])
})
test('Can unbookmark node (Library node bookmark)', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
await comfyPage.settings.setSetting(bookmarksSettingId, [
'KSamplerAdvanced'
])
const tab = comfyPage.menu.nodeLibraryTab
await tab.getFolder('sampling').click()
await expect(tab.getNode('KSampler (Advanced)')).toHaveCount(2)
await tab
.getNodeInFolder('KSampler (Advanced)', 'sampling')
.locator('.bookmark-button')
.click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
await expectBookmarks(comfyPage, [])
})
test('Can customize icon', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Customize').click()
const dialog = comfyPage.page.getByRole('dialog', {
@@ -228,11 +240,7 @@ test.describe('Node library sidebar', () => {
await colorGroup.getByRole('button').nth(1).click()
await dialog.getByRole('button', { name: 'Confirm' }).click()
await comfyPage.nextFrame()
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({
await expectBookmarkCustomization(comfyPage, {
'foo/': {
icon: 'pi-folder',
color: '#007bff'
@@ -241,10 +249,9 @@ test.describe('Node library sidebar', () => {
})
// If color is left as default, it should not be saved
test('Can customize icon (default field)', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Customize').click()
const dialog = comfyPage.page.getByRole('dialog', {
@@ -255,11 +262,7 @@ test.describe('Node library sidebar', () => {
await iconGroup.getByRole('button').nth(1).click()
await dialog.getByRole('button', { name: 'Confirm' }).click()
await comfyPage.nextFrame()
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({
await expectBookmarkCustomization(comfyPage, {
'foo/': {
icon: 'pi-folder'
}
@@ -270,10 +273,9 @@ test.describe('Node library sidebar', () => {
comfyPage
}) => {
// Open customization dialog
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Customize').click()
@@ -302,84 +304,76 @@ test.describe('Node library sidebar', () => {
await comfyPage.nextFrame()
// Verify the color selection is saved
const setting = await comfyPage.settings.getSetting<
Record<string, { icon?: string; color?: string }>
>('Comfy.NodeLibrary.BookmarksCustomization')
await expect(setting).toHaveProperty(['foo/', 'color'])
await expect(setting['foo/'].color).not.toBeNull()
await expect(setting['foo/'].color).not.toBeUndefined()
await expect(setting['foo/'].color).not.toBe('')
await expect
.poll(async () => {
return (
(
await comfyPage.settings.getSetting<BookmarkCustomizationMap>(
bookmarksCustomizationSettingId
)
)['foo/']?.color ?? ''
)
})
.toMatch(/^#.+/)
})
test('Can rename customized bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',
{
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
await comfyPage.settings.setSetting(bookmarksCustomizationSettingId, {
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
)
})
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu-item-label:has-text("Rename")')
.click()
await comfyPage.page.keyboard.insertText('bar')
await comfyPage.page.keyboard.press('Enter')
await renameInlineFolder(comfyPage, 'bar')
await comfyPage.nextFrame()
await expect(async () => {
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({
'bar/': {
icon: 'pi-folder',
color: '#007bff'
await expect
.poll(async () => {
return {
bookmarks:
await comfyPage.settings.getSetting<string[]>(bookmarksSettingId),
customization:
await comfyPage.settings.getSetting<BookmarkCustomizationMap>(
bookmarksCustomizationSettingId
)
}
})
.toEqual({
bookmarks: ['bar/'],
customization: {
'bar/': {
icon: 'pi-folder',
color: '#007bff'
}
}
})
}).toPass({
timeout: 2_000
})
})
test('Can delete customized bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',
{
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
await comfyPage.settings.setSetting(bookmarksCustomizationSettingId, {
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
)
})
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Delete').click()
await comfyPage.nextFrame()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({})
await expectBookmarks(comfyPage, [])
await expectBookmarkCustomization(comfyPage, {})
})
test('Can filter nodes in both trees', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
await comfyPage.settings.setSetting(bookmarksSettingId, [
'foo/',
'foo/KSamplerAdvanced',
'KSampler'

View File

@@ -0,0 +1,94 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import type { ComfyPage } from '../../fixtures/ComfyPage'
async function openVueNodeContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await comfyPage.contextMenu.openForVueNode(fixture.header)
}
test.describe(
'Subgraph Duplicate Independent Values',
{ tag: ['@slow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.vueNodes.waitForNodes()
})
test('Duplicated subgraphs maintain independent widget values', async ({
comfyPage
}) => {
const clipNodeTitle = 'CLIP Text Encode (Prompt)'
// Convert first CLIP Text Encode node to subgraph
await openVueNodeContextMenu(comfyPage, clipNodeTitle)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
await comfyPage.nextFrame()
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
// Duplicate the subgraph
await openVueNodeContextMenu(comfyPage, 'New Subgraph')
await comfyPage.contextMenu.clickMenuItemExact('Duplicate')
await comfyPage.nextFrame()
// Capture both subgraph node IDs
const subgraphNodes = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNodes).toHaveCount(2)
const nodeIds = await subgraphNodes.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute('data-node-id'))
.filter((id): id is string => id !== null)
)
const [nodeId1, nodeId2] = nodeIds
// Enter first subgraph, set text widget value
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea1.fill('subgraph1_value')
await expect(textarea1).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
// Enter second subgraph, set text widget value
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea2.fill('subgraph2_value')
await expect(textarea2).toHaveValue('subgraph2_value')
await comfyPage.subgraph.exitViaBreadcrumb()
// Re-enter first subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea1Again).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
// Re-enter second subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea2Again).toHaveValue('subgraph2_value')
})
}
)

View File

@@ -112,17 +112,17 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
@@ -194,7 +194,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
@@ -206,7 +206,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
@@ -240,12 +240,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
).not.toBeVisible()
// Should still be in subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
@@ -372,7 +372,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
@@ -410,7 +410,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()

View File

@@ -41,8 +41,9 @@ test.describe(
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount + 1)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
@@ -63,15 +64,17 @@ test.describe(
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
expect(afterUndoCount).toBe(initialCount - 1)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
expect(afterRedoCount).toBe(initialCount)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount)
})
}
)

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { fitToViewInstant } from '../../helpers/fitToView'
@@ -8,6 +9,30 @@ import {
getPromotedWidgetCount
} from '../../helpers/promotedWidgets'
async function expectPromotedWidgetNamesToContain(
comfyPage: ComfyPage,
nodeId: string,
widgetName: string
) {
await expect
.poll(() => getPromotedWidgetNames(comfyPage, nodeId), {
timeout: 5000
})
.toContain(widgetName)
}
async function expectPromotedWidgetCountToBeGreaterThan(
comfyPage: ComfyPage,
nodeId: string,
count: number
) {
await expect
.poll(() => getPromotedWidgetCount(comfyPage, nodeId), {
timeout: 5000
})
.toBeGreaterThan(count)
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
@@ -34,12 +59,10 @@ test.describe(
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames).toContain('seed')
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
// SubgraphNode should have widgets (promoted views)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
@@ -54,12 +77,9 @@ test.describe(
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
expect(promotedNames).toContain('text')
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
})
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
@@ -75,13 +95,12 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
expect(promotedNames).toContain('filename_prefix')
await expectPromotedWidgetNamesToContain(
comfyPage,
String(subgraphNode.id),
'filename_prefix'
)
})
})
@@ -160,7 +179,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
@@ -315,8 +334,7 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(widgetCount).toBeGreaterThan(0)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
})
test('Can un-promote a widget from inside a subgraph', async ({
@@ -352,8 +370,8 @@ test.describe(
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
@@ -382,8 +400,11 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
timeout: 5000
})
.toBeLessThan(initialWidgetCount)
})
})
@@ -451,9 +472,11 @@ test.describe(
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames.length).toBeGreaterThan(0)
expect(promotedNames).toContain('filename_prefix')
await expectPromotedWidgetNamesToContain(
comfyPage,
'5',
'filename_prefix'
)
})
test('Converting SaveImage to subgraph promotes its widgets', async ({
@@ -471,11 +494,12 @@ test.describe(
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
await expectPromotedWidgetNamesToContain(
comfyPage,
nodeId,
'filename_prefix'
)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
})
@@ -600,8 +624,8 @@ test.describe(
)
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
expect(initialNames.length).toBeGreaterThan(0)
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
@@ -617,13 +641,16 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
const expectedNames = [...initialNames]
const removedIndex = expectedNames.indexOf(removedSlotName!)
expect(removedIndex).toBeGreaterThanOrEqual(0)
expectedNames.splice(removedIndex, 1)
expect(finalNames).toEqual(expectedNames)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'), {
timeout: 5000
})
.toEqual(expectedNames)
})
test('Removing I/O slot removes associated promoted widget', async ({
@@ -635,8 +662,13 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(initialWidgetCount).toBeGreaterThan(0)
let initialWidgetCount = 0
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
timeout: 5000
})
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
@@ -649,8 +681,11 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
timeout: 5000
})
.toBeLessThan(initialWidgetCount)
})
})
}

View File

@@ -190,11 +190,13 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
await expect(async () => {
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
}).toPass({ timeout: 5000 })
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()

View File

@@ -334,12 +334,12 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})

View File

@@ -48,8 +48,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount + 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('input'))
.toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
@@ -67,8 +68,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount + 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
@@ -86,8 +88,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount - 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('input'))
.toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
@@ -105,8 +108,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount - 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount - 1)
})
})
@@ -135,10 +139,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('input'))
.toBe(RENAMED_INPUT_NAME)
})
test('Can rename input slots via double-click', async ({ comfyPage }) => {
@@ -161,10 +164,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('input'))
.toBe(RENAMED_INPUT_NAME)
})
test('Can rename output slots via double-click', async ({ comfyPage }) => {
@@ -188,10 +190,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('output'))
.toBe(renamedOutputName)
})
test('Right-click context menu still works alongside double-click', async ({
@@ -220,10 +221,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('input'))
.toBe(rightClickRenamedName)
})
test('Can double-click on slot label text to rename', async ({

View File

@@ -0,0 +1,49 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe(
'Zero UUID workflow: subgraph undo rendering',
{ tag: ['@workflow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
test.setTimeout(30000) // Extend timeout as we need to reload the page an additional time
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.page.reload() // Reload page as we need to enter in Vue mode
await comfyPage.page.waitForFunction(() => !!window.app?.graph)
})
test('Undo after subgraph enter/exit renders all nodes when workflow starts with zero UUID', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/basic-subgraph-zero-uuid'
)
await comfyPage.vueNodes.waitForNodes()
const assertInSubgraph = async (inSubgraph: boolean) => {
await expect
.poll(() => comfyPage.subgraph.isInSubgraph())
.toBe(inSubgraph)
}
// Root graph has 1 subgraph node, rendered in the DOM
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
await comfyPage.vueNodes.enterSubgraph()
await assertInSubgraph(true)
await comfyPage.subgraph.exitViaBreadcrumb()
await assertInSubgraph(false)
await comfyPage.canvas.focus()
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
// After undo, the subgraph node is still visible and rendered
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
})
}
)

View File

@@ -10,17 +10,14 @@ import { TestIds } from '../../../../fixtures/selectors'
const BYPASS_CLASS = /before:bg-bypass\/60/
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
await comfyPage.contextMenu.clickMenuItemExact(name)
await comfyPage.nextFrame()
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await fixture.header.click()
await fixture.header.click({ button: 'right' })
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
await comfyPage.contextMenu.openForVueNode(fixture.header)
return comfyPage.contextMenu.primeVueMenu
}
async function openMultiNodeContextMenu(
@@ -100,9 +97,9 @@ test.describe('Vue Node Context Menu', () => {
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test('should duplicate node via context menu', async ({ comfyPage }) => {
@@ -111,9 +108,9 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test('should pin and unpin node via context menu', async ({
@@ -128,7 +125,7 @@ test.describe('Vue Node Context Menu', () => {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await expect(fixture.pinIndicator).toBeVisible()
expect(await nodeRef.isPinned()).toBe(true)
await expect.poll(() => nodeRef.isPinned()).toBe(true)
// Verify drag blocked
const header = fixture.header
@@ -146,7 +143,7 @@ test.describe('Vue Node Context Menu', () => {
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(fixture.pinIndicator).not.toBeVisible()
expect(await nodeRef.isPinned()).toBe(false)
await expect.poll(() => nodeRef.isPinned()).toBe(false)
})
test('should bypass node and remove bypass via context menu', async ({
@@ -158,7 +155,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
expect(await nodeRef.isBypassed()).toBe(true)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
@@ -166,7 +163,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
expect(await nodeRef.isBypassed()).toBe(false)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
@@ -209,6 +206,26 @@ test.describe('Vue Node Context Menu', () => {
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')
.first()
.waitFor({ state: 'visible' })
const [loadImageNode] =
await comfyPage.nodeOps.getNodeRefsByTitle('Load Image')
if (!loadImageNode) throw new Error('Load Image node not found')
await expect
.poll(
() =>
comfyPage.page.evaluate(
(nodeId) =>
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
loadImageNode.id
),
{ timeout: 5_000 }
)
.toBeGreaterThan(0)
})
test('should copy image to clipboard via context menu', async ({
@@ -218,13 +235,16 @@ test.describe('Vue Node Context Menu', () => {
await clickExactMenuItem(comfyPage, 'Copy Image')
// Verify the clipboard contains an image
const hasImage = await comfyPage.page.evaluate(async () => {
const items = await navigator.clipboard.read()
return items.some((item) =>
item.types.some((t) => t.startsWith('image/'))
)
})
expect(hasImage).toBe(true)
await expect
.poll(async () => {
return comfyPage.page.evaluate(async () => {
const items = await navigator.clipboard.read()
return items.some((item) =>
item.types.some((t) => t.startsWith('image/'))
)
})
})
.toBe(true)
})
test('should paste image to LoadImage node via context menu', async ({
@@ -377,9 +397,9 @@ test.describe('Vue Node Context Menu', () => {
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + nodeTitles.length)
})
test('should duplicate selected nodes via context menu', async ({
@@ -390,9 +410,9 @@ test.describe('Vue Node Context Menu', () => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + nodeTitles.length)
})
test('should pin and unpin selected nodes via context menu', async ({
@@ -423,7 +443,7 @@ test.describe('Vue Node Context Menu', () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(true)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
@@ -432,7 +452,7 @@ test.describe('Vue Node Context Menu', () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(false)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)
@@ -504,9 +524,9 @@ test.describe('Vue Node Context Menu', () => {
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount - nodeTitles.length + 1
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount - nodeTitles.length + 1)
})
})
})

View File

@@ -29,23 +29,16 @@ test.describe('Vue Node Moving', () => {
expect(diffY).toBeGreaterThan(0)
}
test(
'should allow moving nodes by dragging',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const loadCheckpointHeaderPos =
await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-moved-node.png')
}
)
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
})
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage

View File

@@ -24,40 +24,43 @@ test.describe('Vue Node Selection', () => {
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await comfyPage.page.getByText('KSampler').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
})
test(`should allow de-selecting nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Load Checkpoint').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
}
test('should select all nodes with ctrl+a', async ({ comfyPage }) => {
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())
.toBeGreaterThan(0)
const initialCount = await comfyPage.vueNodes.getNodeCount()
expect(initialCount).toBeGreaterThan(0)
await comfyPage.canvas.press('Control+a')
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialCount)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBe(initialCount)
})
test('should select pinned node without dragging', async ({ comfyPage }) => {
@@ -73,7 +76,7 @@ test.describe('Vue Node Selection', () => {
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
const initialPos = await checkpointNodeHeader.boundingBox()
if (!initialPos) throw new Error('Failed to get header position')
@@ -87,6 +90,6 @@ test.describe('Vue Node Selection', () => {
if (!finalPos) throw new Error('Failed to get header position after drag')
expect(finalPos).toEqual(initialPos)
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
})

View File

@@ -84,24 +84,25 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Should refresh combo values of nodes with v2 combo input spec', async ({
comfyPage
}) => {
const getComboValues = async () =>
comfyPage.page.evaluate(() => {
return window
.app!.graph!.nodes.find(
(node) => node.title === 'Node With V2 Combo Input'
)!
.widgets!.find((widget) => widget.name === 'combo_input')!.options
.values
})
await comfyPage.workflow.loadWorkflow('inputs/node_with_v2_combo_input')
// click canvas to focus
await comfyPage.page.mouse.click(400, 300)
// press R to trigger refresh
await comfyPage.page.keyboard.press('r')
// wait for nodes' widgets to be updated
await comfyPage.page.mouse.click(400, 300)
await comfyPage.nextFrame()
// get the combo widget's values
const comboValues = await comfyPage.page.evaluate(() => {
return window
.app!.graph!.nodes.find(
(node) => node.title === 'Node With V2 Combo Input'
)!
.widgets!.find((widget) => widget.name === 'combo_input')!.options
.values
})
expect(comboValues).toEqual(['A', 'B'])
await expect
.poll(() => getComboValues(), { timeout: 5_000 })
.toEqual(['A', 'B'])
})
})
@@ -125,6 +126,7 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
window.widgetValue = undefined
const widget = window.app!.graph!.nodes[0].widgets![0]
widget.callback = (value: number) => {
window.widgetValue = value
@@ -133,9 +135,11 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png')
expect(
await comfyPage.page.evaluate(() => window.widgetValue)
).toBeDefined()
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
timeout: 2_000
})
.toBeDefined()
})
})
@@ -146,6 +150,7 @@ test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
window.widgetValue = undefined
const widget = window.app!.graph!.nodes[0].widgets![0]
widget.callback = (value: number) => {
window.widgetValue = value
@@ -154,9 +159,11 @@ test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
expect(
await comfyPage.page.evaluate(() => window.widgetValue)
).toBeDefined()
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
timeout: 2_000
})
.toBeDefined()
})
})
@@ -209,8 +216,7 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
// Expect the filename combo value to be updated
const fileComboWidget = await loadImageNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
await expect.poll(() => fileComboWidget.getValue()).toBe('image32x32.webp')
})
test('Can change image by changing the filename combo value', async ({
@@ -248,8 +254,7 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
)
// Expect the filename combo value to be updated
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
await expect.poll(() => fileComboWidget.getValue()).toBe('image32x32.webp')
})
test('Displays buttons when viewing single image of batch', async ({
comfyPage
@@ -301,8 +306,9 @@ test.describe(
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toContain('animated_webp.webp')
await expect
.poll(() => fileComboWidget.getValue())
.toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
@@ -317,9 +323,20 @@ test.describe(
// Drag and drop image file onto the load animated webp node
await comfyPage.dragDrop.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
dropPosition: { x, y },
waitForUpload: true
})
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.page.evaluate(
(loadId) => window.app!.nodeOutputs[loadId]?.images?.length ?? 0,
loadAnimatedWebpNode.id
),
{ timeout: 10_000 }
)
.toBeGreaterThan(0)
// Get the SaveAnimatedWEBP node
const saveNodes =
@@ -337,11 +354,23 @@ test.describe(
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('.dom-widget').locator('img')
).toHaveCount(2, { timeout: 10_000 })
await expect
.poll(
() =>
comfyPage.page.evaluate(
([loadId, saveId]) => {
const graph = window.app!.graph
return [loadId, saveId].map(
(nodeId) => (graph.getNodeById(nodeId)?.imgs?.length ?? 0) > 0
)
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
),
{ timeout: 10_000 }
)
.toEqual([true, true])
})
}
)

View File

@@ -2,8 +2,45 @@ import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function getNodeOutputImageCount(
comfyPage: ComfyPage,
nodeId: string
): Promise<number> {
return await comfyPage.page.evaluate(
(id) => window.app!.nodeOutputs?.[id]?.images?.length ?? 0,
nodeId
)
}
async function getWidgetValueSnapshot(
comfyPage: ComfyPage
): Promise<Record<string, Array<{ name: string; value: unknown }>>> {
return await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, Array<{ name: string; value: unknown }>> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
}
async function getLinkCount(comfyPage: ComfyPage): Promise<number> {
return await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
}
test.describe('Workflow Persistence', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -71,7 +108,7 @@ test.describe('Workflow Persistence', () => {
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = firstNode!.id
const nodeId = String(firstNode!.id)
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
@@ -91,24 +128,20 @@ test.describe('Workflow Persistence', () => {
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await comfyPage.nextFrame()
const outputsBefore = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsBefore).toBeTruthy()
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
await tab.switchToWorkflow('outputs-test')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
const outputsAfter = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsAfter).toBeTruthy()
expect(outputsAfter?.images).toBeDefined()
await expect
.poll(() => getNodeOutputImageCount(comfyPage, nodeId), {
timeout: 5_000
})
.toBe(1)
})
test('Loading a new workflow cleanly replaces the previous graph', async ({
@@ -149,43 +182,21 @@ test.describe('Workflow Persistence', () => {
// Read widget values via page.evaluate — these are internal LiteGraph
// state not exposed through DOM
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
const widgetValuesBefore = await getWidgetValueSnapshot(comfyPage)
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
await tab.switchToWorkflow('widget-state-test')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
await expect
.poll(() => getWidgetValueSnapshot(comfyPage), {
timeout: 5_000
})
.toEqual(widgetValuesBefore)
})
test('API format workflow with missing node types partially loads', async ({
@@ -234,10 +245,10 @@ test.describe('Workflow Persistence', () => {
button: 'middle',
position: { x: 100, y: 100 }
})
await comfyPage.nextFrame()
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountAfter).toBe(initialNodeCount)
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Exported workflow does not contain transient blob: URLs', async ({
@@ -300,27 +311,85 @@ test.describe('Workflow Persistence', () => {
await tab.open()
// Link count requires internal graph state — not exposed via DOM
const linkCountBefore = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
const linkCountBefore = await getLinkCount(comfyPage)
expect(linkCountBefore).toBeGreaterThan(0)
await comfyPage.menu.topbar.saveWorkflow('links-test')
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
await tab.switchToWorkflow('links-test')
await comfyPage.workflow.waitForWorkflowIdle()
const linkCountAfter = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
await expect
.poll(() => getLinkCount(comfyPage), { timeout: 5_000 })
.toBe(linkCountBefore)
})
test('Closing an unmodified inactive tab preserves both workflows', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — closing inactive tab could corrupt the persisted file'
})
expect(linkCountAfter).toBe(linkCountBefore)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const suffix = Date.now().toString(36)
const nameA = `test-A-${suffix}`
const nameB = `test-B-${suffix}`
// Save the default workflow as A
await comfyPage.menu.topbar.saveWorkflow(nameA)
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
// Create B: duplicate, add a node, then save (unmodified after save)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
})
await comfyPage.nextFrame()
await comfyPage.menu.topbar.saveWorkflow(nameB)
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountB).toBe(nodeCountA + 1)
// Switch to A (making B inactive and unmodified)
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Close inactive B via middle-click — no save dialog expected
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
button: 'middle'
})
await comfyPage.nextFrame()
// A should still have its own content
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Reopen B from saved list
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await workflowsTab.getPersistedItem(nameB).dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
// B should have its original content, not A's
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
})
test('Closing an inactive tab with save preserves its own content', async ({

View File

@@ -93,9 +93,7 @@ test.describe('Zoom Controls', { tag: '@canvas' }, () => {
const input = comfyPage.page
.getByTestId(TestIds.canvas.zoomPercentageInput)
.locator('input')
await input.focus()
await comfyPage.page.keyboard.press('Control+a')
await input.pressSequentially('100')
await input.fill('100')
await input.press('Enter')
await comfyPage.nextFrame()

6
codecov.yml Normal file
View File

@@ -0,0 +1,6 @@
comment:
layout: 'header, diff, flags, files'
behavior: default
require_changes: false
require_base: false
require_head: true

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.14",
"version": "1.43.15",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -58,7 +58,6 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/ingest-types": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
@@ -123,6 +122,7 @@
"zod-validation-error": "catalog:"
},
"devDependencies": {
"@comfyorg/ingest-types": "workspace:*",
"@eslint/js": "catalog:",
"@intlify/eslint-plugin-vue-i18n": "catalog:",
"@lobehub/i18n-cli": "catalog:",

View File

@@ -1 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 226 B

View File

@@ -62,7 +62,8 @@ describe('formatUtil', () => {
{ filename: 'animation.gif', expected: 'image' },
{ filename: 'web.webp', expected: 'image' },
{ filename: 'bitmap.bmp', expected: 'image' },
{ filename: 'modern.avif', expected: 'image' }
{ filename: 'modern.avif', expected: 'image' },
{ filename: 'logo.svg', expected: 'image' }
]
it.for(imageTestCases)(

View File

@@ -542,7 +542,8 @@ const IMAGE_EXTENSIONS = [
'bmp',
'avif',
'tif',
'tiff'
'tiff',
'svg'
] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const

View File

@@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test'
import type { PlaywrightTestConfig } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test'
const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
? {

6
pnpm-lock.yaml generated
View File

@@ -419,9 +419,6 @@ importers:
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
'@comfyorg/ingest-types':
specifier: workspace:*
version: link:packages/ingest-types
'@comfyorg/registry-types':
specifier: workspace:*
version: link:packages/registry-types
@@ -609,6 +606,9 @@ importers:
specifier: 'catalog:'
version: 3.3.0(zod@3.25.76)
devDependencies:
'@comfyorg/ingest-types':
specifier: workspace:*
version: link:packages/ingest-types
'@eslint/js':
specifier: 'catalog:'
version: 9.39.1

View File

@@ -170,6 +170,8 @@ defineExpose({ handleDragDrop })
? `${action.widget.label ?? action.widget.name} ${action.node.title}`
: undefined
"
:data-testid="builderMode ? 'builder-widget-item' : 'app-mode-widget-item'"
:data-widget-key="key"
>
<div
:class="
@@ -181,6 +183,7 @@ defineExpose({ handleDragDrop })
>
<span
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
data-testid="builder-widget-label"
>
{{ action.widget.label || action.widget.name }}
</span>

View File

@@ -1,5 +1,8 @@
<template>
<div class="comfy-error-report flex flex-col gap-4">
<div
data-testid="error-dialog"
class="comfy-error-report flex flex-col gap-4"
>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
@@ -14,11 +17,17 @@
</template>
<div class="flex justify-center gap-2">
<Button v-show="!reportOpen" variant="textonly" @click="showReport">
<Button
v-show="!reportOpen"
data-testid="error-dialog-show-report"
variant="textonly"
@click="showReport"
>
{{ $t('g.showReport') }}
</Button>
<Button
v-show="!reportOpen"
data-testid="error-dialog-contact-support"
variant="textonly"
@click="showContactSupport"
>
@@ -40,7 +49,11 @@
:repo-owner="repoOwner"
:repo-name="repoName"
/>
<Button v-if="reportOpen" @click="copyReportToClipboard">
<Button
v-if="reportOpen"
data-testid="error-dialog-copy-report"
@click="copyReportToClipboard"
>
<i class="pi pi-copy" />
{{ $t('g.copyToClipboard') }}
</Button>

View File

@@ -1,5 +1,9 @@
<template>
<Button variant="secondary" @click="openGitHubIssues">
<Button
data-testid="error-dialog-find-issues"
variant="secondary"
@click="openGitHubIssues"
>
<i class="pi pi-github" />
{{ $t('g.findIssues') }}
</Button>

View File

@@ -143,14 +143,14 @@ describe('Autogrow', () => {
test('Can add autogrow with min input count', () => {
const node = testNode()
addAutogrow(node, { min: 4, input: inputsSpec })
expect(node.inputs.length).toBe(4)
expect(node.inputs.length).toBe(5)
})
test('Adding connections will cause growth up to max', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test', max: 3 })
expect(node.inputs.length).toBe(1)
expect(node.inputs.length).toBe(2)
connectInput(node, 0, graph)
expect(node.inputs.length).toBe(2)
@@ -159,7 +159,7 @@ describe('Autogrow', () => {
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(3)
})
test('Removing connections decreases to min', async () => {
test('Removing connections decreases to min + 1', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
@@ -204,9 +204,9 @@ describe('Autogrow', () => {
'0.a0',
'0.a1',
'0.a2',
'1.b0',
'1.b1',
'1.b2',
'2.b0',
'2.b1',
'2.b2',
'aa'
])
})

View File

@@ -511,7 +511,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
lastInput
)
}
const removalChecks = groupInputs.slice((min - 1) * stride)
const removalChecks = groupInputs.slice(min * stride)
let i
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
@@ -597,6 +597,6 @@ function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
prefix,
inputSpecs: inputsV2
}
for (let i = 0; i === 0 || i < min; i++)
for (let i = 0; i === 0 || i < min + 1; i++)
addAutogrowGroup(i, inputSpecV2.name, node)
}

View File

@@ -12,6 +12,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
@@ -1005,3 +1006,25 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
})
})
describe('Zero UUID handling in configure', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
})
it('rejects zeroUuid for root graphs and assigns a new ID', () => {
const graph = new LGraph()
const data = graph.serialize()
data.id = zeroUuid
graph.configure(data)
expect(graph.id).not.toBe(zeroUuid)
})
it('preserves zeroUuid for subgraphs', () => {
const graph = new LGraph()
const subgraphData = { ...createTestSubgraphData(), id: zeroUuid }
const subgraph = graph.createSubgraph(subgraphData)
subgraph.configure(subgraphData)
expect(subgraph.id).toBe(zeroUuid)
})
})

View File

@@ -2480,8 +2480,8 @@ export class LGraph
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
const { id, extra } = data
// Create a new graph ID if none is provided
if (id) {
// Create a new graph ID if none is provided or the zero UUID is used on the root graph
if (id && !(this.isRootGraph && id === zeroUuid)) {
this.id = id
} else if (this.id === zeroUuid) {
this.id = createUuidv4()

View File

@@ -0,0 +1,157 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
name: 'Civitai',
hostnames: ['civitai.com'],
fetchMetadata: vi.fn()
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
name: 'HuggingFace',
hostnames: ['huggingface.co'],
fetchMetadata: vi.fn()
}
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addEventListener: vi.fn(),
apiURL: vi.fn((path: string) => path)
}
}))
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback,
t: (key: string) => key,
te: () => false,
d: (date: Date) => date.toISOString()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('useUploadModelWizard', () => {
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates uploadStatus to success when async download completes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const asyncResponse: AsyncUploadResponse = {
type: 'async',
task: {
task_id: 'task-123',
status: 'created',
message: 'Download queued'
}
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(wizard.uploadStatus.value).toBe('processing')
// Simulate WebSocket: download completes
const detail = {
task_id: 'task-123',
asset_id: 'asset-456',
asset_name: 'model.safetensors',
bytes_total: 1000,
bytes_downloaded: 1000,
progress: 100,
status: 'completed' as const
}
const event = new CustomEvent('asset_download', { detail })
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(event)
await nextTick()
expect(wizard.uploadStatus.value).toBe('success')
})
it('updates uploadStatus to error when async download fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const asyncResponse: AsyncUploadResponse = {
type: 'async',
task: {
task_id: 'task-fail',
status: 'created',
message: 'Download queued'
}
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(wizard.uploadStatus.value).toBe('processing')
// Simulate WebSocket: download fails
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
const failEvent = new CustomEvent('asset_download', {
detail: {
task_id: 'task-fail',
asset_id: '',
asset_name: 'model.safetensors',
bytes_total: 1000,
bytes_downloaded: 500,
progress: 50,
status: 'failed' as const,
error: 'Network error'
}
})
expect(handler).toBeDefined()
handler!(failEvent)
await nextTick()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('Network error')
})
})

View File

@@ -36,6 +36,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
let stopAsyncWatch: (() => void) | undefined
const wizardData = ref<WizardData>({
url: '',
@@ -203,6 +204,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
async function uploadModel(): Promise<boolean> {
if (isUploading.value) return false
if (!canUploadModel.value) {
return false
}
@@ -247,6 +249,44 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
)
}
uploadStatus.value = 'processing'
stopAsyncWatch?.()
let resolved = false
const stop = watch(
() =>
assetDownloadStore.downloadList.find(
(d) => d.taskId === result.task.task_id
)?.status,
async (status) => {
if (status === 'completed') {
resolved = true
uploadStatus.value = 'success'
await refreshModelCaches()
stopAsyncWatch?.()
stopAsyncWatch = undefined
} else if (status === 'failed') {
resolved = true
const download = assetDownloadStore.downloadList.find(
(d) => d.taskId === result.task.task_id
)
uploadStatus.value = 'error'
uploadError.value =
download?.error ||
t('assetBrowser.downloadFailed', {
name: download?.assetName || ''
})
stopAsyncWatch?.()
stopAsyncWatch = undefined
}
},
{ immediate: true }
)
if (resolved) {
stop()
stopAsyncWatch = undefined
} else {
stopAsyncWatch = stop
}
} else {
uploadStatus.value = 'success'
await refreshModelCaches()
@@ -271,6 +311,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
function resetWizard() {
stopAsyncWatch?.()
stopAsyncWatch = undefined
currentStep.value = 1
isFetchingMetadata.value = false
isUploading.value = false

View File

@@ -14,6 +14,11 @@ export function appendCloudResParam(
filename?: string
): void {
if (!isCloud) return
if (filename && getMediaTypeFromFilename(filename) !== 'image') return
if (
filename &&
(getMediaTypeFromFilename(filename) !== 'image' ||
filename.toLowerCase().endsWith('.svg'))
)
return
params.set('res', '512')
}

View File

@@ -38,6 +38,7 @@
<!-- Asset unsupported group notice -->
<div
v-if="isCloud && !group.isAssetSupported"
data-testid="missing-model-import-unsupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i

View File

@@ -16,6 +16,7 @@
</p>
<Button
data-testid="missing-model-copy-name"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
@@ -32,6 +33,7 @@
<Button
v-if="!isCloud && model.representative.url && !isAssetSupported"
data-testid="missing-model-copy-url"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@@ -62,6 +64,7 @@
<Button
v-if="model.referencingNodes.length > 0"
data-testid="missing-model-expand"
variant="textonly"
size="icon-sm"
:aria-label="
@@ -106,6 +109,7 @@
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
@@ -148,6 +152,7 @@
class="flex w-full items-start py-1"
>
<Button
data-testid="missing-model-download"
variant="secondary"
size="md"
class="flex w-full flex-1"

View File

@@ -88,10 +88,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import { isDOMWidget } from '@/scripts/domWidget'
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
@@ -108,12 +107,14 @@ const props = defineProps<{
// Audio element ref
const audioRef = ref<HTMLAudioElement>()
// Keep track of the last uploaded path as a backup
let lastUploadedPath = ''
// Composables
const recorder = useAudioRecorder({
onRecordingComplete: handleRecordingComplete,
onRecordingComplete: async (blob) => handleRecordingComplete(blob),
onStop: () => {
pauseTimer()
waveform.stopWaveform()
waveform.dispose()
},
onError: () => {
useToastStore().addAlert(
t('g.micPermissionDenied') || 'Microphone permission denied'
@@ -154,20 +155,33 @@ const { isPlaying, audioElementKey } = playback
// Computed for waveform animation
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
const modelValue = defineModel<string>({ default: '' })
const litegraphNode = computed(() => {
if (!props.nodeId || !app.canvas.graph) return null
return app.canvas.graph.getNodeById(props.nodeId) as LGraphNode | null
})
async function handleRecordingComplete(blob: Blob) {
try {
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
modelValue.value = path
lastUploadedPath = path
} catch (e) {
useToastStore().addAlert('Failed to upload recorded audio')
function handleRecordingComplete(blob: Blob) {
// Create a widget-owned blob URL (independent of useAudioRecorder's
// recordedURL which gets revoked on re-record or unmount) and set it
// on the litegraph audioUI DOM widget's element. The original
// serializeValue (in uploadAudio.ts) reads element.src, fetches the
// blob, and uploads at serialization time.
const node = litegraphNode.value
if (!node?.widgets) return
for (const w of node.widgets) {
if (
!(
isDOMWidget<HTMLAudioElement, string>(w) &&
w.element instanceof HTMLAudioElement
)
)
continue
if (w.element.src.startsWith('blob:')) {
URL.revokeObjectURL(w.element.src)
}
w.element.src = URL.createObjectURL(blob)
break
}
}
@@ -198,8 +212,6 @@ async function handleStartRecording() {
function handleStopRecording() {
recorder.stopRecording()
pauseTimer()
waveform.stopWaveform()
}
async function handlePlayRecording() {
@@ -258,42 +270,8 @@ function handlePlaybackEnded() {
}
}
// Serialization function for workflow execution
async function serializeValue() {
if (isRecording.value && recorder.mediaRecorder.value) {
recorder.mediaRecorder.value.stop()
await new Promise((resolve, reject) => {
let attempts = 0
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
const checkRecording = () => {
if (!isRecording.value && modelValue.value) {
resolve(undefined)
} else if (++attempts >= maxAttempts) {
reject(new Error('Recording serialization timeout after 5 seconds'))
} else {
setTimeout(checkRecording, 100)
}
}
checkRecording()
})
}
return modelValue.value || lastUploadedPath || ''
}
function registerWidgetSerialization() {
const node = litegraphNode.value
if (!node?.widgets) return
const targetWidget = node.widgets.find((w: IBaseWidget) => w.name === 'audio')
if (targetWidget) {
targetWidget.serializeValue = serializeValue
}
}
onMounted(() => {
waveform.initWaveform()
registerWidgetSerialization()
})
onUnmounted(() => {

View File

@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import { useAudioRecorder } from '@/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder'
const MockMediaRecorder = vi.hoisted(() =>
vi.fn(
class {
state = 'recording'
start = vi.fn()
onstop: (() => void) | null = null
ondataavailable: ((e: { data: Blob }) => void) | null = null
stop = vi.fn(() => {
this.state = 'inactive'
this.onstop?.()
})
}
)
)
vi.mock('extendable-media-recorder', () => ({
MediaRecorder: MockMediaRecorder
}))
vi.mock('@/services/audioService', () => ({
useAudioService: () => ({
registerWavEncoder: vi.fn().mockResolvedValue(undefined)
})
}))
function createMockTrack() {
return { kind: 'audio', readyState: 'live', stop: vi.fn() }
}
function createMockStream(tracks = [createMockTrack()]) {
return { getTracks: () => tracks } as unknown as MediaStream
}
const mockGetUserMedia = vi.fn()
vi.stubGlobal('navigator', {
mediaDevices: { getUserMedia: mockGetUserMedia }
})
function recorderInstance() {
return MockMediaRecorder.mock.instances[0]
}
describe('useAudioRecorder', () => {
beforeEach(() => {
MockMediaRecorder.mockClear()
mockGetUserMedia.mockResolvedValue(createMockStream())
})
afterEach(() => {
vi.restoreAllMocks()
})
it('starts recording and sets isRecording to true', async () => {
const scope = effectScope()
const { isRecording, startRecording } = scope.run(() => useAudioRecorder())!
await startRecording()
expect(isRecording.value).toBe(true)
expect(mockGetUserMedia).toHaveBeenCalledWith({ audio: true })
expect(recorderInstance().start).toHaveBeenCalledWith(100)
scope.stop()
})
it('stops recording and releases microphone tracks', async () => {
const mockStream = createMockStream()
mockGetUserMedia.mockResolvedValue(mockStream)
const scope = effectScope()
const { isRecording, startRecording, stopRecording } = scope.run(() =>
useAudioRecorder()
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(isRecording.value).toBe(false))
const tracks = mockStream.getTracks()
expect(tracks[0].stop).toHaveBeenCalled()
scope.stop()
})
it('creates blob URL after stopping', async () => {
const scope = effectScope()
const { recordedURL, startRecording, stopRecording } = scope.run(() =>
useAudioRecorder()
)!
await startRecording()
recorderInstance().ondataavailable?.({
data: new Blob(['audio'], { type: 'audio/wav' })
})
stopRecording()
await vi.waitFor(() => expect(recordedURL.value).not.toBeNull())
expect(recordedURL.value).toMatch(/^blob:/)
scope.stop()
})
it('calls onRecordingComplete with blob after stop', async () => {
const onComplete = vi.fn().mockResolvedValue(undefined)
const scope = effectScope()
const { startRecording, stopRecording } = scope.run(() =>
useAudioRecorder({ onRecordingComplete: onComplete })
)!
await startRecording()
recorderInstance().ondataavailable?.({
data: new Blob(['chunk'], { type: 'audio/wav' })
})
stopRecording()
await vi.waitFor(() => expect(onComplete).toHaveBeenCalled())
expect(onComplete).toHaveBeenCalledWith(expect.any(Blob))
scope.stop()
})
it('releases mic BEFORE calling onRecordingComplete', async () => {
const mockStream = createMockStream()
mockGetUserMedia.mockResolvedValue(mockStream)
const tracks = mockStream.getTracks()
let micReleasedBeforeCallback = false
const onComplete = vi.fn().mockImplementation(async () => {
micReleasedBeforeCallback =
vi.mocked(tracks[0].stop).mock.calls.length > 0
})
const scope = effectScope()
const { startRecording, stopRecording } = scope.run(() =>
useAudioRecorder({ onRecordingComplete: onComplete })
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(onComplete).toHaveBeenCalled())
expect(micReleasedBeforeCallback).toBe(true)
scope.stop()
})
it('calls onStop synchronously during cleanup', async () => {
const onStop = vi.fn()
const scope = effectScope()
const { startRecording, stopRecording } = scope.run(() =>
useAudioRecorder({ onStop })
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(onStop).toHaveBeenCalled())
scope.stop()
})
it('sets mediaRecorder to null in cleanup', async () => {
const scope = effectScope()
const { mediaRecorder, startRecording, stopRecording } = scope.run(() =>
useAudioRecorder()
)!
await startRecording()
expect(mediaRecorder.value).not.toBeNull()
stopRecording()
await vi.waitFor(() => expect(mediaRecorder.value).toBeNull())
scope.stop()
})
it('calls onError when getUserMedia fails', async () => {
mockGetUserMedia.mockRejectedValue(new Error('Permission denied'))
const onError = vi.fn()
const scope = effectScope()
const { isRecording, startRecording } = scope.run(() =>
useAudioRecorder({ onError })
)!
await expect(startRecording()).rejects.toThrow('Permission denied')
expect(onError).toHaveBeenCalled()
expect(isRecording.value).toBe(false)
scope.stop()
})
it('dispose stops recording and revokes blob URL', async () => {
const revokeURL = vi.spyOn(URL, 'revokeObjectURL')
const scope = effectScope()
const { recordedURL, startRecording, stopRecording, dispose } = scope.run(
() => useAudioRecorder()
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(recordedURL.value).not.toBeNull())
const blobUrl = recordedURL.value
dispose()
expect(recordedURL.value).toBeNull()
expect(revokeURL).toHaveBeenCalledWith(blobUrl)
scope.stop()
})
it('cleanup is idempotent when called on inactive recorder', () => {
const scope = effectScope()
const { isRecording, stopRecording } = scope.run(() => useAudioRecorder())!
expect(isRecording.value).toBe(false)
stopRecording()
expect(isRecording.value).toBe(false)
scope.stop()
})
})

View File

@@ -5,6 +5,7 @@ import { useAudioService } from '@/services/audioService'
interface AudioRecorderOptions {
onRecordingComplete?: (audioBlob: Blob) => Promise<void>
onStop?: () => void
onError?: (error: Error) => void
}
@@ -48,12 +49,12 @@ export function useAudioRecorder(options: AudioRecorderOptions = {}) {
}
recordedURL.value = URL.createObjectURL(blob)
cleanup()
// Notify completion
if (options.onRecordingComplete) {
await options.onRecordingComplete(blob)
}
cleanup()
}
// Start recording
@@ -77,11 +78,14 @@ export function useAudioRecorder(options: AudioRecorderOptions = {}) {
function cleanup() {
isRecording.value = false
mediaRecorder.value = null
if (stream.value) {
stream.value.getTracks().forEach((track) => track.stop())
stream.value = null
}
options.onStop?.()
}
function dispose() {

View File

@@ -12,6 +12,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
export const VIEWPORT_CACHE_MAX_SIZE = 32
@@ -138,11 +139,19 @@ export const useSubgraphNavigationStore = defineStore(
return
}
// Cache miss — fit to content after the canvas has the new graph.
// rAF fires after layout + paint, when nodes are positioned.
const expectedGraphId = graphId
// Cache miss — fit to content only if no nodes are currently visible.
// loadGraphData may have already restored extra.ds or called fitView
// for templates, so only intervene when the viewport is truly empty.
requestAnimationFrame(() => {
if (getActiveGraphId() !== expectedGraphId) return
if (getActiveGraphId() !== graphId) return
if (!canvas.graph) return
const nodes = canvas.graph.nodes
if (!nodes?.length) return
canvas.ds.computeVisibleArea(canvas.viewport)
if (anyItemOverlapsRect(nodes, canvas.ds.visible_area)) return
useLitegraphService().fitView()
})
}

View File

@@ -24,8 +24,11 @@ vi.mock('@/scripts/app', () => {
scale: 1,
offset: [0, 0],
state: { scale: 1, offset: [0, 0] },
fitToBounds: vi.fn()
fitToBounds: vi.fn(),
visible_area: [0, 0, 1000, 1000],
computeVisibleArea: vi.fn()
},
viewport: [0, 0, 1000, 1000],
setDirty: mockSetDirty,
get empty() {
return true
@@ -184,6 +187,11 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
// Ensure no cached entry
store.viewportCache.delete(':root')
// Add a node outside the visible area so anyItemOverlapsRect returns false
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [9999, 9999], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
// Use the root graph ID so the stale-guard passes
store.restoreViewport('root')
@@ -194,6 +202,10 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
// Cleanup
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips fitView if active graph changed before rAF fires', () => {