Compare commits

...

11 Commits

Author SHA1 Message Date
Kelly Yang
fa7477b8c4 test: add Preview3D execution flow E2E tests 2026-04-09 22:13:30 -07:00
Alexander Brown
0132c77c7d test: harden 82 Playwright specs for deterministic CI runs (#10967)
## Summary

Harden 98 E2E spec files and 8 fixtures/helpers for deterministic CI
runs by replacing race-prone patterns with retry-safe alternatives.

No source code changes -- only `browser_tests/` is touched.

## Changes

- **E2E spec hardening** (98 spec files, 6 fixtures, 2 helpers):

  | Fix class | Sites | Examples |
  |-----------|-------|---------:|
| `expect(await ...)` -> `expect.poll()` | ~153 | interaction,
defaultKeybindings, workflows, featureFlags |
| `const x = await loc.count(); expect(x)` -> `toHaveCount()` | ~19 |
menu, linkInteraction, assets, bottomPanelShortcuts |
| `nextFrame()` -> `waitForHidden()` after menu clicks | ~22 |
contextMenu, rightClickMenu, subgraphHelper |
| Redundant `nextFrame()` removed | many | defaultKeybindings, minimap,
builderSaveFlow |
| `expect(async () => { ... }).toPass()` retry blocks | 5 | interaction
(graphdialog dismiss guard) |
| `force:true` removed from `BaseDialog.close()` | 1 | BaseDialog
fixture |
| ContextMenu `waitForHidden` simplified (check-then-act race removed) |
1 | ContextMenu fixture |
| Non-deterministic node order -> proximity-based selection | 1 |
interaction (toggle dom widget) |
  | Tight poll timeout (250ms) -> >=2000ms | 2 | templates |

- **Helper improvements**: Exposed locator getters on
`ComfyPage.domWidgets`, `ToastHelper.toastErrors`, and
`WorkflowsSidebarTab.activeWorkflowLabel` so callers can use retrying
assertions (`toHaveCount()`, `toHaveText()`) directly.

- **Flake pattern catalog**: Added section 7 table to
`browser_tests/FLAKE_PREVENTION_RULES.md` documenting 8 pattern classes
for reviewers and future authors.

- **Docs**: Fixed bad examples in `browser_tests/README.md` to use
`expect.poll()`.

- **Breaking**: None
- **Dependencies**: None

## Review Focus

- All fixes follow the rules in
`browser_tests/FLAKE_PREVENTION_RULES.md`
- No behavioral changes to tests -- only timing/retry strategy is
updated
- The `ContextMenu.waitForHidden` simplification removes a
swallowed-error anti-pattern; both locators now use direct `waitFor({
state: 'hidden' })`

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-09 20:50:56 -07:00
Terry Jia
63eab15c4f Range editor (#10936)
BE change https://github.com/Comfy-Org/ComfyUI/pull/13322

## Summary
Add RANGE widget for image levels adjustment       
- Add RangeEditor widget with three display modes: plain, gradient, and
histogram
- Support optional midpoint (gamma) control for non-linear midtone
adjustment
- Integrate histogram display from upstream node outputs

## Screenshots (if applicable)
<img width="1450" height="715" alt="image"
src="https://github.com/user-attachments/assets/864976af-9eb7-4dd0-9ce1-2f5d7f003117"
/>
<img width="1431" height="701" alt="image"
src="https://github.com/user-attachments/assets/7ee2af65-f87a-407b-8bf2-6ec59a1dff59"
/>
<img width="705" height="822" alt="image"
src="https://github.com/user-attachments/assets/7bcb8f17-795f-498a-9f8a-076ed6c05a98"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10936-Range-editor-33b6d73d365081089e8be040b40f6c8a)
by [Unito](https://www.unito.io)
2026-04-09 18:37:40 -07:00
Terry Jia
277ee5c32e test: add E2E tests for Load3D model upload and drag-drop and basic e2e for 3d viewer (#10957)
## Summary
Add tests verifying real model loading:
- Upload cube.obj via file chooser button
- Drag-and-drop cube.obj onto the 3D canvas
- Add data-testid to LoadingOverlay for stable test selectors.
Add tests verifying 3d viewer openning:
- Open viewer from Load3D node via expand button, verify canvas and
controls sidebar
- Cancel button closes the viewer dialog

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10957-test-add-E2E-tests-for-Load3D-model-upload-and-drag-drop-and-basic-e2e-for-3d-viewer-33c6d73d3650810c8ff8ed656a5164a6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-09 21:36:07 -04:00
Dante
e8787dee9d fix: prevent node context menu from overflowing viewport on desktop (#10854)
## Summary

The node "More Options" (⋮) context menu had `md:max-h-none
md:overflow-y-visible` responsive overrides that removed the height
constraint and scrollability on desktop (768px+). When the menu had many
items (e.g., KSampler), items below the viewport fold were inaccessible
with no scrollbar.

Removed the desktop overrides so `max-h-[80vh] overflow-y-auto` applies
at all screen sizes.

- Fixes #10824

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for node context menu viewport overflow` |
🔴 Red | Proves the test catches the bug |
| `fix: prevent node context menu from overflowing viewport on desktop`
| 🟢 Green | Proves the fix resolves the bug |

## Test Plan

- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] Manual verification: zoom out to 50%, open node More Options menu,
verify last item ("Remove") is scrollable

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10854-fix-prevent-node-context-menu-from-overflowing-viewport-on-desktop-3396d73d365081989403c981847aeda6)
by [Unito](https://www.unito.io)
2026-04-10 10:31:04 +09:00
Dante
ba0bab3e50 test: add E2E tests for ManagerDialog (#10970)
## Summary
- Add Playwright E2E tests for the ManagerDialog component which had
zero test coverage
- Covers dialog opening, pack browsing, search filtering, tab
navigation, sort controls, search mode switching, error states, install
action, info panel, and close behavior
- All API calls (Algolia search, Manager installed packs, queue) are
mocked with typed responses

Part of the FixIt Burndown test coverage initiative.

## Test plan
- [ ] CI browser tests pass
- [ ] Tests validate core ManagerDialog user flows with mocked APIs
- [ ] No regressions in existing tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10970-test-add-E2E-tests-for-ManagerDialog-33c6d73d365081468a4ad8fc1894f05b)
by [Unito](https://www.unito.io)
2026-04-10 10:26:35 +09:00
Kelly Yang
bbb07053c4 test: add E2E tests for CanvasModeSelector toolbar component (#10934)
Adds `browser_tests/tests/canvasModeSelector.spec.ts`, covering the
canvas toolbar mode-selector component that was introduced with no E2E
coverage.
  Covers:
- Trigger button: toolbar visibility, `aria-expanded` state, icon
reflects active mode
- Popover lifecycle: open on click, close on re-click / item selection /
Escape
- Mode switching: clicking Hand/Select drives `canvas.state.readOnly`;
clicking the active item is a no-op
- ARIA state: `aria-checked` and roving `tabindex` track active mode,
including state driven by external commands
- Keyboard navigation: ArrowDown/Up with wraparound, Escape restores
focus to trigger — all using `toBeFocused()` retrying assertions
- Focus management: popover auto-focuses the checked item on open
- Keybinding integration: `H` / `V` keys update both
`canvas.state.readOnly` and the trigger icon
- Shortcut hint display: both menu items render non-empty key-sequence
hints
  22 tests across 7 `describe` groups. All selectors are ARIA-driven.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10934-test-add-E2E-tests-for-CanvasModeSelector-toolbar-component-33b6d73d3650819cb2cfdca22bf0b9a5)
by [Unito](https://www.unito.io)
2026-04-09 20:44:30 -04:00
Christian Byrne
97fca566fb fix: use || instead of ?? and server type in WebcamCapture upload path (#11000)
## Description

Fixes the WebcamCapture image upload path construction that was still
broken on cloud environments after #10220.

### Root cause

The cloud `/upload/image` endpoint returns:
```json
{ "name": "hash.png", "subfolder": "", "type": "input" }
```

The previous fix used `??` (nullish coalescing), which doesn't catch
empty strings:
- `subfolder: ""` → `"" ?? "webcam"` = `""` → path becomes `/hash.png`
(wrong)
- `type` was hardcoded as `[temp]` but cloud stores as `input` → file
not found

### Fix

- `??` → `||` so empty strings fall back to defaults
- Use `data.type` from server response instead of hardcoding `[temp]`

### QA evidence

Prod (cloud/1.42): `ImageDownloadError: the input file
'webcam/1775685296883.png [temp]' doesn't exist`
Staging (cloud/1.42): `ImageDownloadError: Failed to validate images`

### Related

- Fixes the remaining issue from #10220

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11000-fix-use-instead-of-and-server-type-in-WebcamCapture-upload-path-33d6d73d36508156b93cfce0aae8e017)
by [Unito](https://www.unito.io)
2026-04-09 16:08:45 -07:00
Christian Byrne
c6b8883e61 [chore] Update Ingest API types from cloud@48d94b7 (#10925)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 48d94b7
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-09 14:41:53 -07:00
Christian Byrne
8487c13f14 feat: integrate Typeform survey into feedback button (#10890)
## Summary

Replace Zendesk feedback URLs with Typeform survey (`q7azbWPi`) in the
action bar feedback button and Help Center menu for Cloud/Nightly
distributions.

## Changes

- **What**: 
- `cloudFeedbackTopbarButton.ts`: Replace `buildFeedbackUrl()` (Zendesk)
with direct Typeform survey URL. Remove unused Zendesk import.
- `HelpCenterMenuContent.vue`: Feedback menu item now opens Typeform URL
for Cloud/Nightly builds; falls back to `Comfy.ContactSupport` (Zendesk)
for other distributions. Added external link icon for Cloud/Nightly.
- Help menu item and `Comfy.ContactSupport` command unchanged — support
flows still route to Zendesk.

## Review Focus

- Gating logic: `isCloud || isNightly` correctly limits Typeform
redirect to intended distributions
- Help item intentionally unchanged (support ≠ feedback)

Ticket: COM-17992

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10890-feat-integrate-Typeform-survey-into-feedback-button-33a6d73d36508185abbfe57e7a36b5f7)
by [Unito](https://www.unito.io)
2026-04-09 14:40:52 -07:00
jaeone94
809da9c11c fix: use cloud assets for asset widget default value (#10983)
## Summary

In cloud mode, asset-supported nodes (e.g. CheckpointLoaderSimple) used
the server's `object_info` combo options as their default widget value.
These options list local files on the backend which may not exist in the
user's cloud asset library. When the missing-model pipeline runs (on
undo, reload, or tab switch), it checks widget values against cloud
assets and correctly flags these local-only files as missing — producing
errors that appear to be false positives but are actually valid
detections of unusable defaults.

This PR changes the default value source from server combo options to
the cloud assets store.

## Default Value Behavior (Before → After)

### Cloud + asset-supported widgets (changed)

| Condition | Before | After |
|-----------|--------|-------|
| Assets cached, `inputSpec.default` in assets | `inputSpec.default` |
`inputSpec.default` |
| Assets cached, `inputSpec.default` not in assets | `inputSpec.default`
| `assets[0]` |
| Assets cached, no `inputSpec.default`, `options` exist | `options[0]`
| `assets[0]` |
| Assets not cached, `inputSpec.default` exists | `inputSpec.default` |
`undefined` → "Select model" |
| Assets not cached, no `inputSpec.default`, `options` exist |
`options[0]` | `undefined` → "Select model" |
| Assets not cached, no `inputSpec.default`, no `options` | `undefined`
→ "Select model" | `undefined` → "Select model" |

### Cloud + non-asset widgets (unchanged)

| Condition | Behavior |
|-----------|----------|
| `inputSpec.default` exists | `inputSpec.default` |
| `options` exist | `options[0]` |
| `remote` input | `"Loading..."` |
| None | `undefined` |

### OSS (unchanged)

| Condition | Behavior |
|-----------|----------|
| `inputSpec.default` exists | `inputSpec.default` |
| `options` exist | `options[0]` |
| `remote` input | `"Loading..."` |
| None | `undefined` |

## Root Cause

1. `addComboWidget` called `getDefaultValue(inputSpec)` which returns
`inputSpec.options[0]` — a local file from `object_info`
2. In cloud mode, `shouldUseAssetBrowser()` creates an asset widget with
this local filename as default
3. The model (e.g.
`dynamicrafter/controlnet/dc-sketch_encoder_fp16.safetensors`) exists on
the server but not in the user's cloud asset library
4. On undo/reload, `verifyAssetSupportedCandidates()` checks the widget
value against cloud assets → not found → marked as missing

## Changes

### Production (`useComboWidget.ts`)
- New `resolveCloudDefault(nodeType, specDefault)` function encapsulates
cloud default resolution
- Default priority: `inputSpec.default` (if found in cloud assets) →
first cloud asset → `undefined` (shows "Select model" placeholder)
- Edge case guards: `!= null` check for falsy defaults, `|| undefined`
for empty `getAssetFilename` return
- Server combo options (`object_info`) are no longer used as defaults
for asset widgets

### Unit Tests (`useComboWidget.test.ts`)
- 6 scenarios covering all default value paths:
  - Cloud assets loaded, no `inputSpec.default` → `assets[0]`
  - Cloud assets loaded, `inputSpec.default` in assets → uses default
  - Cloud assets loaded, `inputSpec.default` not in assets → `assets[0]`
  - No cloud assets, with `inputSpec.default` → placeholder
  - No cloud assets, with server options → placeholder
  - Asset widget creation verification
- Test helper refactored: assertions moved from helper to each test for
clarity

### E2E Test (`cloud-asset-default.spec.ts`)
- New `@cloud` tagged test verifying CheckpointLoaderSimple uses first
cloud asset, not server default
- Fixture extension stubs `/api/assets` before app loads (local backend
returns 503 for this endpoint)
- Uses typed mock data from existing `assetFixtures.ts`

## Scope

- **Cloud only**: All changes gated behind `isCloud` +
`shouldUseAssetBrowser()`
- **OSS impact**: None — code path is not entered in non-cloud builds
- **Breaking changes**: None — `useComboWidget` export signature
unchanged

## Review Focus
- Should the `/api/assets` stub in the E2E fixture extension be moved
into `ComfyPage` for all `@cloud` tests?

## Record
Before 


https://github.com/user-attachments/assets/994162a0-b56a-4e84-9b1c-d0f0068196d5



After


https://github.com/user-attachments/assets/ba299990-9bd3-4565-bd09-bffac3db60a9
2026-04-09 15:44:04 +09:00
159 changed files with 5884 additions and 2128 deletions

View File

@@ -0,0 +1,246 @@
---
name: hardening-flaky-e2e-tests
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
---
# Hardening Flaky E2E Tests
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
## Workflow
### 1. Gather CI Evidence
```bash
gh run list --workflow=ci-test.yaml --limit=5
gh run download <run-id> -n playwright-report
```
- Open `report.json` and search for `"status": "flaky"` entries.
- Collect file paths, test titles, and error messages.
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
- 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. Classify the Flake
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
| # | Pattern | Signature in Code | Fix |
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
### 3. Apply the Transform
#### Rule: Choose the Smallest Correct Assertion
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
- **Never** use `waitForTimeout()` to hide a race.
```typescript
// ✅ Single value — use expect.poll
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
.toBe(3)
// ✅ Locator count — use toHaveCount
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
// ✅ Multiple conditions — use toPass
await expect(async () => {
expect(await node1.getValue()).toBe('foo')
expect(await node2.getValue()).toBe('bar')
}).toPass({ timeout: 5000 })
```
#### Rule: Wait for the Real Readiness Boundary
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
Common readiness boundaries:
| After this action... | Wait for... |
| -------------------------------------- | ------------------------------------------------------------ |
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
| Menu item click | `await contextMenu.waitForHidden()` |
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
| Settings write | Poll the setting value with `expect.poll()` |
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
| Graph mutation (add/remove node, link) | Poll link/node count |
| Clipboard write | Poll pasted value |
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
#### Rule: Expose Locators for Retrying Assertions
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
```typescript
// Helper exposes locator
get domWidgets(): Locator {
return this.page.locator('.dom-widget')
}
// Caller uses retrying assertion
await expect(comfyPage.domWidgets).toHaveCount(2)
```
Replace count methods with locator getters so callers can use retrying assertions directly.
#### Rule: Fix Check-then-Act Races in Helpers
```typescript
// ❌ Race: count can change between check and waitFor
const count = await locator.count()
if (count > 0) {
await locator.waitFor({ state: 'hidden' })
}
// ✅ Direct: waitFor handles both cases
await locator.waitFor({ state: 'hidden' })
```
#### Rule: Remove force:true from Clicks
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
```typescript
// ❌ Hides the race
await closeButton.click({ force: true })
// ✅ Surfaces the real issue — fix with proper wait
await closeButton.click()
await dialog.waitForHidden()
```
#### Rule: Handle Non-deterministic Element Order
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
```typescript
// ❌ Assumes order
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
// ✅ Find by ID or proximity
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
let target = nodes[0]
for (const n of nodes) {
const pos = await n.getPosition()
if (Math.abs(pos.y - expectedY) < minDist) target = n
}
```
Or guard the assumption:
```typescript
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes).toHaveLength(1)
const node = nodes[0]
```
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
```typescript
// ✅ Retry click+assert together
await expect(async () => {
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
await expect(dialog).toBeHidden({ timeout: 500 })
}).toPass({ timeout: 5000 })
```
### 4. Keep 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.
### 5. Verify Narrowly
```bash
# Targeted rerun with repetition
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
# Single test by line number (avoids grep quoting issues on Windows)
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
```
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
- Verify with the smallest command that exercises the flaky path.
### 6. Watch CI E2E Runs
After pushing, use `gh` to monitor the E2E workflow:
```bash
# Find the run for the current branch
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
# Watch it live (blocks until complete, streams logs)
gh run watch <run-id>
# One-liner: find and watch the latest E2E run for the current branch
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
```
On Windows (PowerShell):
```powershell
# One-liner equivalent
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
```
After the run completes:
```bash
# Download the Playwright report artifact
gh run download <run-id> -n playwright-report
# View the run summary in browser
gh run view <run-id> --web
```
Also watch the unit test workflow in parallel if you changed helpers:
```bash
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
```
### 7. Pre-merge Checklist
Before merging a flaky-test fix, confirm:
- [ ] 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 (poll vs toHaveCount vs toPass)
- [ ] The fix stays local unless a shared helper truly owns the race
- [ ] Local verification uses a targeted rerun
- [ ] No behavioral changes to the test — only timing/retry strategy updated
## Local Noise — Do Not Fix
These are local distractions, not 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:
- 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

@@ -83,6 +83,21 @@ await expect
a different reproduction pattern.
- Verify with the smallest command that exercises the flaky path.
## 7. Common Flake Patterns
| Pattern | Bad | Fix |
| ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width).toBe(w)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width)).toBe(w)` |
| **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
| **Fake readiness helper** | Helper that clicks but doesn't assert state | Remove; poll the actual value |
| **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
| **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000ms; prefer default (5000ms) |
| **Immediate count()** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
| **Immediate evaluate after mutation** | `setSetting(); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
| **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
## Current Local Noise
These are local distractions, not automatic CI root causes:

View File

@@ -210,8 +210,8 @@ Most common testing needs are already addressed by these helpers, which will mak
```typescript
// Prefer this:
expect(await node.isPinned()).toBe(true)
expect(await node.getProperty('title')).toBe('Expected Title')
await expect.poll(() => node.isPinned()).toBe(true)
await expect.poll(() => node.getProperty('title')).toBe('Expected Title')
// Over this - only use when needed:
await expect(comfyPage.canvas).toHaveScreenshot('state.png')

View File

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

View File

@@ -0,0 +1,40 @@
# Blender 5.2.0 Alpha
# www.blender.org
mtllib Untitled.mtl
o Cube
v 2.857396 2.486626 -0.081892
v 2.857396 0.486626 -0.081892
v 2.857396 2.486626 1.918108
v 2.857396 0.486626 1.918108
v 0.857396 2.486626 -0.081892
v 0.857396 0.486626 -0.081892
v 0.857396 2.486626 1.918108
v 0.857396 0.486626 1.918108
vn -0.0000 1.0000 -0.0000
vn -0.0000 -0.0000 1.0000
vn -1.0000 -0.0000 -0.0000
vn -0.0000 -1.0000 -0.0000
vn 1.0000 -0.0000 -0.0000
vn -0.0000 -0.0000 -1.0000
vt 0.625000 0.500000
vt 0.875000 0.500000
vt 0.875000 0.750000
vt 0.625000 0.750000
vt 0.375000 0.750000
vt 0.625000 1.000000
vt 0.375000 1.000000
vt 0.375000 0.000000
vt 0.625000 0.000000
vt 0.625000 0.250000
vt 0.375000 0.250000
vt 0.125000 0.500000
vt 0.375000 0.500000
vt 0.125000 0.750000
s 0
usemtl Material
f 1/1/1 5/2/1 7/3/1 3/4/1
f 4/5/2 3/4/2 7/6/2 8/7/2
f 8/8/3 7/9/3 5/10/3 6/11/3
f 6/12/4 2/13/4 4/5/4 8/14/4
f 2/13/5 1/1/5 3/4/5 4/5/5
f 6/11/6 5/10/6 1/1/6 2/13/6

View File

@@ -392,9 +392,8 @@ export class ComfyPage {
await modal.waitFor({ state: 'hidden' })
}
/** Get number of DOM widgets on the canvas. */
async getDOMWidgetCount() {
return await this.page.locator('.dom-widget').count()
get domWidgets(): Locator {
return this.page.locator('.dom-widget')
}
async setFocusMode(focusMode: boolean) {

View File

@@ -48,13 +48,6 @@ export class VueNodeHelpers {
return await this.nodes.count()
}
/**
* Get count of selected Vue nodes
*/
async getSelectedNodeCount(): Promise<number> {
return await this.selectedNodes.count()
}
/**
* Get all Vue node IDs currently in the DOM
*/

View File

@@ -25,7 +25,7 @@ export class BaseDialog {
}
async close(): Promise<void> {
await this.closeButton.click({ force: true })
await this.closeButton.click()
await this.waitForHidden()
}
}

View File

@@ -65,21 +65,9 @@ export class ContextMenu {
}
async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()
if (count > 0) {
await locator.waitFor({ state: 'hidden' }).catch((error: Error) => {
console.warn(
`[waitForHidden] ${menuName} waitFor failed:`,
error.message
)
})
}
}
await Promise.all([
waitIfExists(this.primeVueMenu, 'primeVueMenu'),
waitIfExists(this.litegraphMenu, 'litegraphMenu')
this.primeVueMenu.waitFor({ state: 'hidden' }),
this.litegraphMenu.waitFor({ state: 'hidden' })
])
}
}

View File

@@ -168,10 +168,14 @@ export class WorkflowsSidebarTab extends SidebarTab {
.allInnerTexts()
}
get activeWorkflowLabel(): Locator {
return this.root.locator(
'.comfyui-workflows-open .p-tree-node-selected .node-label'
)
}
async getActiveWorkflowName() {
return await this.root
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
.innerText()
return await this.activeWorkflowLabel.innerText()
}
async getTopLevelSavedWorkflowNames() {

View File

@@ -0,0 +1,25 @@
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
import { Load3DViewerHelper } from '@e2e/tests/load3d/Load3DViewerHelper'
export const load3dTest = comfyPageFixture.extend<{
load3d: Load3DHelper
}>({
load3d: async ({ comfyPage }, use) => {
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')
await use(new Load3DHelper(node))
}
})
export const load3dViewerTest = load3dTest.extend<{
viewer: Load3DViewerHelper
}>({
viewer: async ({ comfyPage }, use) => {
await comfyPage.settings.setSetting('Comfy.Load3D.3DViewerEnable', true)
await use(new Load3DViewerHelper(comfyPage.page))
}
})

View File

@@ -0,0 +1,121 @@
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
export async function waitForAppBootstrapped(
comfyPage: ComfyPage
): Promise<void> {
await comfyPage.page.waitForFunction(() =>
Boolean(window.app?.extensionManager)
)
await comfyPage.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
await comfyPage.nextFrame()
}
export function vecClose(
a: { x: number; y: number; z: number },
b: { x: number; y: number; z: number },
eps: number
): boolean {
return (
Math.abs(a.x - b.x) < eps &&
Math.abs(a.y - b.y) < eps &&
Math.abs(a.z - b.z) < eps
)
}
export function cameraStatesClose(
a: unknown,
b: unknown,
eps: number
): boolean {
if (
a === null ||
b === null ||
typeof a !== 'object' ||
typeof b !== 'object'
)
return false
const ca = a as {
position?: { x: number; y: number; z: number }
target?: { x: number; y: number; z: number }
zoom?: number
cameraType?: string
}
const cb = b as typeof ca
if (
!ca.position ||
!ca.target ||
!cb.position ||
!cb.target ||
ca.cameraType !== cb.cameraType
)
return false
if (Math.abs((ca.zoom ?? 0) - (cb.zoom ?? 0)) > eps) return false
return (
vecClose(ca.position, cb.position, eps) &&
vecClose(ca.target, cb.target, eps)
)
}
export class Preview3DPipelineContext {
static readonly loadNodeId = '1'
static readonly previewNodeId = '2'
readonly load3d: Load3DHelper
readonly preview3d: Load3DHelper
constructor(readonly comfyPage: ComfyPage) {
this.load3d = new Load3DHelper(
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.loadNodeId)
)
this.preview3d = new Load3DHelper(
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.previewNodeId)
)
}
async getModelFileWidgetValue(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const w = n?.widgets?.find((x) => x.name === 'model_file')
return typeof w?.value === 'string' ? w.value : ''
}, nodeId)
}
async getLastTimeModelFile(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const v = n?.properties?.['Last Time Model File']
return typeof v === 'string' ? v : ''
}, nodeId)
}
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const cfg = n?.properties?.['Camera Config'] as
| { state?: unknown }
| undefined
return cfg?.state ?? null
}, nodeId)
}
}
export const preview3dPipelineTest = comfyPageFixture.extend<{
preview3dPipeline: Preview3DPipelineContext
}>({
preview3dPipeline: async ({ comfyPage }, use) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.workflow.loadWorkflow('3d/preview3d_pipeline')
await comfyPage.vueNodes.waitForNodes()
const pipeline = new Preview3DPipelineContext(comfyPage)
await use(pipeline)
await comfyPage.workflow.setupWorkflowsDirectory({})
}
})

View File

@@ -445,7 +445,7 @@ export class SubgraphHelper {
await this.rightClickOutputSlot(slotName)
}
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await this.comfyPage.nextFrame()
await this.comfyPage.contextMenu.waitForHidden()
}
async findSubgraphNodeId(): Promise<string> {

View File

@@ -8,14 +8,8 @@ export class ToastHelper {
return this.page.locator('.p-toast-message:visible')
}
async getToastErrorCount(): Promise<number> {
return await this.page
.locator('.p-toast-message.p-toast-message-error')
.count()
}
async getVisibleToastCount(): Promise<number> {
return await this.visibleToasts.count()
get toastErrors(): Locator {
return this.page.locator('.p-toast-message.p-toast-message-error')
}
async closeToasts(requireCount = 0): Promise<void> {

View File

@@ -155,6 +155,12 @@ export const TestIds = {
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
},
loading: {
overlay: 'loading-overlay'
},
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
}
} as const
@@ -185,3 +191,5 @@ export type TestIdValue =
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]

View File

@@ -26,9 +26,12 @@ export class ManageGroupNode {
await this.footer.getByText('Close').click()
}
get selectedNodeTypeSelect(): Locator {
return this.header.locator('select').first()
}
async getSelectedNodeType() {
const select = this.header.locator('select').first()
return await select.inputValue()
return await this.selectedNodeTypeSelect.inputValue()
}
async selectNode(name: string) {

View File

@@ -22,7 +22,24 @@ export async function getPromotedWidgets(
): Promise<PromotedWidgetEntry[]> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return node?.properties?.proxyWidgets ?? []
const widgets = node?.widgets ?? []
// Read the live promoted widget views from the host node instead of the
// serialized proxyWidgets snapshot, which can lag behind the current graph
// state during promotion and cleanup flows.
return widgets.flatMap((widget) => {
if (
widget &&
typeof widget === 'object' &&
'sourceNodeId' in widget &&
typeof widget.sourceNodeId === 'string' &&
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
) {
return [[widget.sourceNodeId, widget.sourceWidgetName]]
}
return []
})
}, nodeId)
return normalizePromotedWidgets(raw)

View File

@@ -22,10 +22,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}) => {
// Enable change auto-queue mode
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
expect(await queueOpts.getMode()).toBe('disabled')
await expect.poll(() => queueOpts.getMode()).toBe('disabled')
await queueOpts.setMode('change')
await comfyPage.nextFrame()
expect(await queueOpts.getMode()).toBe('change')
await expect.poll(() => queueOpts.getMode()).toBe('change')
await comfyPage.actionbar.queueButton.toggleOptions()
// Intercept the prompt queue endpoint
@@ -124,6 +124,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
force: true
}
)
expect(await comfyPage.actionbar.isDocked()).toBe(true)
await expect(comfyPage.actionbar.root.locator('.actionbar')).toHaveClass(
/static/
)
})
})

View File

@@ -92,19 +92,23 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const overlay = comfyPage.page.locator('.p-select-overlay').first()
await expect(overlay).toBeVisible({ timeout: 5000 })
const isInViewport = await overlay.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
await expect
.poll(() =>
overlay.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
)
})
expect(isInViewport).toBe(true)
.toBe(true)
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
await expect
.poll(() => overlay.evaluate(isClippedByAnyAncestor))
.toBe(false)
})
test('FormDropdown popup is not clipped in app mode panel', async ({
@@ -142,18 +146,22 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
await expect
.poll(() =>
popover.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
)
})
expect(isInViewport).toBe(true)
.toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
await expect
.poll(() => popover.evaluate(isClippedByAnyAncestor))
.toBe(false)
})
})

View File

@@ -1,7 +1,7 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode welcome states', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -103,14 +103,15 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
const keyBadges = bottomPanel.shortcuts.keyBadges
await keyBadges.first().waitFor({ state: 'visible' })
const count = await keyBadges.count()
expect(count).toBeGreaterThanOrEqual(1)
await expect.poll(() => keyBadges.count()).toBeGreaterThanOrEqual(1)
const badgeText = await keyBadges.allTextContents()
const hasModifiers = badgeText.some((text) =>
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
)
expect(hasModifiers).toBeTruthy()
await expect
.poll(() => keyBadges.allTextContents())
.toEqual(
expect.arrayContaining([
expect.stringMatching(/^(Ctrl|Cmd|Shift|Alt)$/)
])
)
})
test('should maintain panel state when switching between panels', async ({
@@ -196,8 +197,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
).toBeVisible()
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
const titleCount = await subcategoryTitles.count()
expect(titleCount).toBeGreaterThanOrEqual(2)
await expect.poll(() => subcategoryTitles.count()).toBeGreaterThanOrEqual(2)
})
test('should open shortcuts panel with Ctrl+Shift+K', async ({

View File

@@ -21,7 +21,7 @@ async function saveCloseAndReopenAsApp(
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
await expect(appMode.saveAs.successDialog).not.toBeVisible()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)

View File

@@ -3,6 +3,7 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import {
builderSaveAs,
openWorkflowFromSidebar,
@@ -24,6 +25,15 @@ async function reSaveAs(
await appMode.saveAs.fillAndSave(workflowName, viewType)
}
async function dismissSuccessDialog(
saveAs: BuilderSaveAsHelper,
button: 'close' | 'dismiss' = 'close'
) {
const btn = button === 'close' ? saveAs.closeButton : saveAs.dismissButton
await btn.click()
await expect(saveAs.successDialog).not.toBeVisible()
}
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
@@ -121,8 +131,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
await saveAs.closeButton.click()
await comfyPage.nextFrame()
await dismissSuccessDialog(saveAs)
// Modify the workflow so the save button becomes enabled
await comfyPage.appMode.steps.goToInputs()
@@ -143,8 +152,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
await saveAs.closeButton.click()
await comfyPage.nextFrame()
await dismissSuccessDialog(saveAs)
await footer.openSaveAsFromChevron()
@@ -161,8 +169,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await appMode.enterBuilder()
// State 1: Disabled "Save as" (no outputs selected)
await expect(appMode.footer.saveAsButton).toBeVisible()
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(disabledBox).toBeTruthy()
if (!disabledBox)
throw new Error('saveAsButton boundingBox returned null while visible')
const disabledWidth = disabledBox.width
// Select I/O to enable the button
await appMode.steps.goToInputs()
@@ -171,19 +182,20 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await appMode.select.selectOutputNode('Save Image')
// State 2: Enabled "Save as" (unsaved, has outputs)
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(enabledBox).toBeTruthy()
expect(enabledBox!.width).toBe(disabledBox!.width)
await expect
.poll(
async () => (await appMode.footer.saveAsButton.boundingBox())?.width
)
.toBe(disabledWidth)
// Save the workflow to transition to the Save + chevron state
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
await dismissSuccessDialog(appMode.saveAs)
// State 3: Save + chevron button group (saved workflow)
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
expect(saveButtonGroupBox).toBeTruthy()
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
await expect
.poll(async () => (await appMode.footer.saveGroup.boundingBox())?.width)
.toBe(disabledWidth)
})
test('Connect output popover appears when no outputs selected', async ({
@@ -206,11 +218,13 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toContain('.app.json')
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toContain('.app.json')
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
expect(linearMode).toBe(true)
await expect
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
.toBe(true)
})
test('save as node graph produces correct extension and linearMode', async ({
@@ -223,12 +237,15 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
'Node graph'
)
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toMatch(/\.json$/)
expect(path).not.toContain('.app.json')
await expect(async () => {
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toMatch(/\.json$/)
expect(path).not.toContain('.app.json')
}).toPass({ timeout: 5000 })
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
expect(linearMode).toBe(false)
await expect
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
.toBe(false)
})
test('save as app View App button enters app mode', async ({ comfyPage }) => {
@@ -236,11 +253,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
await comfyPage.appMode.saveAs.viewAppButton.click()
await comfyPage.nextFrame()
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
expect(await comfyPage.workflow.getActiveWorkflowActiveAppMode()).toBe(
'app'
)
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowActiveAppMode())
.toBe('app')
})
test('save as node graph Exit builder exits builder mode', async ({
@@ -254,7 +271,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
)
await comfyPage.appMode.saveAs.exitBuilderButton.click()
await comfyPage.nextFrame()
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
})
@@ -267,27 +284,27 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
const originalName = `${Date.now()} original`
await builderSaveAs(appMode, originalName, 'App')
const originalPath = await comfyPage.workflow.getActiveWorkflowPath()
expect(originalPath).toContain('.app.json')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toContain('.app.json')
await dismissSuccessDialog(appMode.saveAs)
// Re-save as node graph — creates a copy
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
const newPath = await comfyPage.workflow.getActiveWorkflowPath()
expect(newPath).not.toBe(originalPath)
expect(newPath).not.toContain('.app.json')
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.not.toContain('.app.json')
// Dismiss success dialog, exit app mode, reopen the original
await appMode.saveAs.dismissButton.click()
await comfyPage.nextFrame()
await dismissSuccessDialog(appMode.saveAs, 'dismiss')
await appMode.toggleAppMode()
await openWorkflowFromSidebar(comfyPage, originalName)
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
expect(linearMode).toBe(true)
await expect
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
.toBe(true)
})
test('save as with same name and same mode overwrites in place', async ({
@@ -298,20 +315,25 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await builderSaveAs(appMode, name, 'App')
await appMode.saveAs.closeButton.click()
await dismissSuccessDialog(appMode.saveAs)
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toContain('.app.json')
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
await reSaveAs(appMode, name, 'App')
await expect(appMode.saveAs.overwriteDialog).toBeVisible({ timeout: 5000 })
await appMode.saveAs.overwriteButton.click()
await expect(appMode.saveAs.overwriteDialog).not.toBeVisible()
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
expect(pathAfterSecond).toBe(pathAfterFirst)
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toBe(pathAfterFirst)
})
test('save as with same name but different mode creates a new file', async ({
@@ -322,32 +344,38 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await builderSaveAs(appMode, name, 'App')
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toContain('.app.json')
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
expect(pathAfterFirst).toContain('.app.json')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
await dismissSuccessDialog(appMode.saveAs)
await reSaveAs(appMode, name, 'Node graph')
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
expect(pathAfterSecond).not.toBe(pathAfterFirst)
expect(pathAfterSecond).toMatch(/\.json$/)
expect(pathAfterSecond).not.toContain('.app.json')
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.not.toBe(pathAfterFirst)
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toMatch(/\.json$/)
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.not.toContain('.app.json')
})
test('save as app workflow reloads in app mode', async ({ comfyPage }) => {
const name = `${Date.now()} reload-app`
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, name, 'App')
await comfyPage.appMode.saveAs.dismissButton.click()
await comfyPage.nextFrame()
await dismissSuccessDialog(comfyPage.appMode.saveAs, 'dismiss')
await comfyPage.appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, name)
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
expect(mode).toBe('app')
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowInitialMode())
.toBe('app')
})
test('save as node graph workflow reloads in node graph mode', async ({
@@ -356,13 +384,13 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
const name = `${Date.now()} reload-graph`
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, name, 'Node graph')
await comfyPage.appMode.saveAs.dismissButton.click()
await comfyPage.nextFrame()
await dismissSuccessDialog(comfyPage.appMode.saveAs, 'dismiss')
await comfyPage.appMode.toggleAppMode()
await openWorkflowFromSidebar(comfyPage, name)
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
expect(mode).toBe('graph')
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowInitialMode())
.toBe('graph')
})
})

View File

@@ -0,0 +1,275 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const getLocators = (page: Page) => ({
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
})
const MODES = [
{
label: 'Select',
activateCommand: 'Comfy.Canvas.Unlock',
isReadOnly: false,
iconPattern: /lucide--mouse-pointer-2/
},
{
label: 'Hand',
activateCommand: 'Comfy.Canvas.Lock',
isReadOnly: true,
iconPattern: /lucide--hand/
}
]
test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
await comfyPage.nextFrame()
})
test.describe('Trigger button', () => {
test('visible in canvas toolbar with ARIA markup', async ({
comfyPage
}) => {
const { trigger } = getLocators(comfyPage.page)
await expect(trigger).toBeVisible()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
for (const mode of MODES) {
test(`shows ${mode.label}-mode icon on trigger button`, async ({
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
await expect(modeIcon).toHaveClass(mode.iconPattern)
})
}
})
test.describe('Popover lifecycle', () => {
test('opens when trigger is clicked', async ({ comfyPage }) => {
const { trigger, menu } = getLocators(comfyPage.page)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
test('closes when trigger is clicked again', async ({ comfyPage }) => {
const { trigger, menu } = getLocators(comfyPage.page)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
test('closes after a mode item is selected', async ({ comfyPage }) => {
const { trigger, menu, handItem } = getLocators(comfyPage.page)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await handItem.click()
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
})
test('closes when Escape is pressed', async ({ comfyPage }) => {
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await selectItem.press('Escape')
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
})
test.describe('Mode switching', () => {
for (const mode of MODES) {
test(`clicking "${mode.label}" sets canvas readOnly=${mode.isReadOnly}`, async ({
comfyPage
}) => {
if (!mode.isReadOnly) {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
}
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
const item = mode.isReadOnly ? handItem : selectItem
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await item.click()
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.isReadOnly())
.toBe(mode.isReadOnly)
})
}
test('clicking the currently active item is a no-op', async ({
comfyPage
}) => {
expect(
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts in Select mode'
).toBe(false)
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await selectItem.click()
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('ARIA state', () => {
test('aria-checked marks Select active on default load', async ({
comfyPage
}) => {
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await expect(selectItem).toHaveAttribute('aria-checked', 'true')
await expect(handItem).toHaveAttribute('aria-checked', 'false')
})
for (const mode of MODES) {
test(`tabindex=0 is on the active "${mode.label}" item`, async ({
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
const activeItem = mode.isReadOnly ? handItem : selectItem
const inactiveItem = mode.isReadOnly ? selectItem : handItem
await expect(activeItem).toHaveAttribute('tabindex', '0')
await expect(inactiveItem).toHaveAttribute('tabindex', '-1')
})
}
})
test.describe('Keyboard navigation', () => {
test('ArrowDown moves focus from Select to Hand', async ({ comfyPage }) => {
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await selectItem.press('ArrowDown')
await expect(handItem).toBeFocused()
})
test('Escape closes popover and restores focus to trigger', async ({
comfyPage
}) => {
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await selectItem.press('ArrowDown')
await handItem.press('Escape')
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
await expect(trigger).toBeFocused()
})
})
test.describe('Focus management on open', () => {
for (const mode of MODES) {
test(`auto-focuses the checked "${mode.label}" item on open`, async ({
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
const item = mode.isReadOnly ? handItem : selectItem
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
await expect(item).toBeFocused()
})
}
})
test.describe('Keybinding integration', { tag: '@keyboard' }, () => {
test("'H' locks canvas and updates trigger icon to Hand", async ({
comfyPage
}) => {
expect(
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts unlocked'
).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
await expect(modeIcon).toHaveClass(/lucide--hand/)
})
test("'V' unlocks canvas and updates trigger icon to Select", async ({
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts locked'
).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
await expect(modeIcon).toHaveClass(/lucide--mouse-pointer-2/)
})
})
test.describe('Shortcut hint display', () => {
test('menu items show non-empty keyboard shortcut text', async ({
comfyPage
}) => {
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeVisible()
const selectHint = selectItem.getByTestId('shortcut-hint')
const handHint = handItem.getByTestId('shortcut-hint')
await expect(selectHint).not.toBeEmpty()
await expect(handHint).not.toBeEmpty()
})
})
})

View File

@@ -1,14 +1,94 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { WorkspaceStore } from '@e2e/types/globals'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
type ChangeTrackerDebugState = {
changeCount: number
graphMatchesActiveState: boolean
isLoadingGraph: boolean
isModified: boolean | undefined
redoQueueSize: number
restoringState: boolean
undoQueueSize: number
}
async function getChangeTrackerDebugState(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate(() => {
type ChangeTrackerClassLike = {
graphEqual: (left: unknown, right: unknown) => boolean
isLoadingGraph: boolean
}
type ChangeTrackerLike = {
_restoringState: boolean
activeState: unknown
changeCount: number
constructor: ChangeTrackerClassLike
redoQueue: unknown[]
undoQueue: unknown[]
}
type ActiveWorkflowLike = {
changeTracker?: ChangeTrackerLike
isModified?: boolean
}
const workflowStore = window.app!.extensionManager as WorkspaceStore
const workflow = workflowStore.workflow
.activeWorkflow as ActiveWorkflowLike | null
const tracker = workflow?.changeTracker
if (!workflow || !tracker) {
throw new Error('Active workflow change tracker is not available')
}
const currentState = JSON.parse(
JSON.stringify(window.app!.rootGraph.serialize())
)
return {
changeCount: tracker.changeCount,
graphMatchesActiveState: tracker.constructor.graphEqual(
tracker.activeState,
currentState
),
isLoadingGraph: tracker.constructor.isLoadingGraph,
isModified: workflow.isModified,
redoQueueSize: tracker.redoQueue.length,
restoringState: tracker._restoringState,
undoQueueSize: tracker.undoQueue.length
} satisfies ChangeTrackerDebugState
})
}
async function waitForChangeTrackerSettled(
comfyPage: ComfyPage,
expected: Pick<
ChangeTrackerDebugState,
'isModified' | 'redoQueueSize' | 'undoQueueSize'
>
) {
// Visible node flags can flip before undo finishes loadGraphData() and
// updates the tracker. Poll the tracker's own settled state so we do not
// start the next transaction while checkState() is still gated.
await expect
.poll(() => getChangeTrackerDebugState(comfyPage))
.toMatchObject({
changeCount: 0,
graphMatchesActiveState: true,
isLoadingGraph: false,
restoringState: false,
...expected
})
}
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitBeforeChange()
})
}
async function afterChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitAfterChange()
@@ -32,7 +112,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
await expect.poll(() => comfyPage.toast.getToastErrorCount()).toBe(0)
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
@@ -59,19 +139,19 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
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 waitForChangeTrackerSettled(comfyPage, {
isModified: true,
redoQueueSize: 1,
undoQueueSize: 1
})
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(2)
await waitForChangeTrackerSettled(comfyPage, {
isModified: false,
redoQueueSize: 2,
undoQueueSize: 0
})
})
})
@@ -98,6 +178,11 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
await waitForChangeTrackerSettled(comfyPage, {
isModified: false,
redoQueueSize: 2,
undoQueueSize: 0
})
// Prevent clicks registering a double-click
await comfyPage.canvasOps.clickEmptySpace()
@@ -113,11 +198,21 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
// End transaction
await afterChange(comfyPage)
await waitForChangeTrackerSettled(comfyPage, {
isModified: true,
redoQueueSize: 0,
undoQueueSize: 1
})
// Ensure undo reverts both changes
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
await expect(node).not.toBeBypassed({ timeout: 5000 })
await expect(node).not.toBeCollapsed({ timeout: 5000 })
await waitForChangeTrackerSettled(comfyPage, {
isModified: false,
redoQueueSize: 1,
undoQueueSize: 0
})
})
test('Can nest multiple change transactions without adding undo steps', async ({

View File

@@ -16,7 +16,7 @@ test.describe(
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
@@ -42,25 +42,21 @@ test.describe(
// Create tab 1: blank workflow (0 nodes)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Switch back to tab 0 (workflow-a).
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// switch to blank tab and back to verify no corruption
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
await tab1.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// switch again and verify no corruption
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
})
}
)

View File

@@ -0,0 +1,89 @@
import { expect } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('should use first cloud asset when server default is not in assets', async ({
comfyPage
}) => {
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
{ timeout: 10_000 }
)
.toBe('asset')
// Add a new CheckpointLoaderSimple — should use first cloud asset,
// not the server's object_info default.
// Production resolves via getAssetFilename (user_metadata.filename →
// metadata.filename → asset.name). Test fixtures have no metadata
// filename, so asset.name is the resolved value.
const nodeId = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
window.app!.graph.add(node!)
return node!.id
})
await expect
.poll(async () => {
return await comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
}, nodeId)
})
.toBe(CLOUD_ASSETS[0].name)
})
})

View File

@@ -35,8 +35,8 @@ test.describe(
await node.toggleCollapse()
await comfyPage.nextFrame()
await expect.poll(async () => await node.boundingBox()).not.toBeNull()
const box = await node.boundingBox()
expect(box).not.toBeNull()
await comfyPage.page.mouse.move(
box!.x + box!.width / 2,
box!.y + box!.height / 2

View File

@@ -157,6 +157,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark-all-colors.png'
)
@@ -177,7 +178,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
window.app!.extensionManager as WorkspaceStore
).colorPalette.addCustomColorPalette(p)
}, customColorPalettes.obsidian_dark)
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
@@ -211,12 +212,14 @@ test.describe(
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
@@ -225,8 +228,8 @@ test.describe(
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.2-arc-theme.png'
)
@@ -238,22 +241,38 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
if (typeof graph.serialize !== 'function') {
throw new Error('app.graph.serialize is not available')
}
return graph.serialize() as {
nodes: Array<{ bgcolor?: string; color?: string }>
}
})
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
const nodes = parsed.nodes
for (const node of nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
if (typeof graph.serialize !== 'function') return undefined
const parsed = graph.serialize() as {
nodes: Array<{ bgcolor?: string; color?: string }>
}
return parsed.nodes
})
)
.toBeDefined()
await expect
.poll(async () => {
const nodes = await comfyPage.page.evaluate(() => {
return (
window.app!.graph!.serialize() as {
nodes: Array<{ bgcolor?: string; color?: string }>
}
).nodes
})
if (!Array.isArray(nodes)) return 'not an array'
for (const node of nodes) {
if (node.bgcolor && /hsla/.test(node.bgcolor))
return `bgcolor contains hsla: ${node.bgcolor}`
if (node.color && /hsla/.test(node.color))
return `color contains hsla: ${node.color}`
}
return 'ok'
})
.toBe('ok')
})
test('should lighten node colors when switching to light theme', async ({

View File

@@ -13,7 +13,9 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.foo))
.toBe(true)
})
test('Should execute async command', async ({ comfyPage }) => {
@@ -27,7 +29,9 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.foo))
.toBe(true)
})
test('Should handle command errors', async ({ comfyPage }) => {
@@ -36,7 +40,7 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
await expect(comfyPage.toast.toastErrors).toHaveCount(1)
})
test('Should handle async command errors', async ({ comfyPage }) => {
@@ -49,6 +53,6 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
await expect(comfyPage.toast.toastErrors).toHaveCount(1)
})
})

View File

@@ -4,22 +4,19 @@ import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
async function verifyCustomIconSvg(iconElement: Locator) {
const svgVariable = await iconElement.evaluate((element) => {
const styles = getComputedStyle(element)
return styles.getPropertyValue('--svg')
})
expect(svgVariable).toBeTruthy()
const dataUrlMatch = svgVariable.match(
/url\("data:image\/svg\+xml,([^"]+)"\)/
)
expect(dataUrlMatch).toBeTruthy()
const encodedSvg = dataUrlMatch![1]
const decodedSvg = decodeURIComponent(encodedSvg)
// Check for SVG header to confirm it's a valid SVG
expect(decodedSvg).toContain("<svg xmlns='http://www.w3.org/2000/svg'")
await expect
.poll(async () => {
const svgVariable = await iconElement.evaluate((element) =>
getComputedStyle(element).getPropertyValue('--svg')
)
if (!svgVariable) return null
const dataUrlMatch = svgVariable.match(
/url\("data:image\/svg\+xml,([^"]+)"\)/
)
if (!dataUrlMatch) return null
return decodeURIComponent(dataUrlMatch[1])
})
.toContain("<svg xmlns='http://www.w3.org/2000/svg'")
}
test.describe('Custom Icons', { tag: '@settings' }, () => {

View File

@@ -41,11 +41,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
@@ -58,8 +56,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
@@ -68,15 +67,17 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
@@ -85,29 +86,30 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.toBeGreaterThan(0.1)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
@@ -122,15 +124,15 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await expect.poll(() => node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await expect.poll(() => node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await expect.poll(() => node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
@@ -147,16 +149,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await expect.poll(() => getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await expect.poll(() => getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
await expect.poll(() => getMode()).toBe(0)
})
})
@@ -170,12 +172,10 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
@@ -189,11 +189,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
@@ -203,11 +201,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
@@ -278,7 +274,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
.toBe(true)
})
})
@@ -286,8 +284,14 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
await expect
.poll(
() => comfyPage.nodeOps.getGraphNodesCount(),
'Default workflow should have multiple nodes'
)
.toBeGreaterThan(1)
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')

View File

@@ -17,10 +17,9 @@ test.describe('Settings', () => {
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
const isUsableHeight = await contentArea.evaluate(
(el) => el.clientHeight > 30
)
expect(isUsableHeight).toBeTruthy()
await expect
.poll(() => contentArea.evaluate((el) => el.clientHeight))
.toBeGreaterThan(30)
})
test('Can open settings with hotkey', async ({ comfyPage }) => {
@@ -39,27 +38,27 @@ test.describe('Settings', () => {
const maxSpeed = 2.5
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await test.step('Setting should persist', async () => {
expect(await comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
maxSpeed
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed'))
.toBe(maxSpeed)
})
})
test('Should persist keybinding setting', async ({ comfyPage }) => {
// Open the settings dialog
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
// Open the keybinding tab
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })
.click()
await comfyPage.page.waitForSelector(
'[placeholder="Search Keybindings..."]'
)
await expect(
comfyPage.page.getByPlaceholder('Search Keybindings...')
).toBeVisible()
// Focus the 'New Blank Workflow' row
const newBlankWorkflowRow = comfyPage.page.locator('tr', {
@@ -156,6 +155,6 @@ test.describe('Signin dialog', () => {
await input.press('Control+v')
await expect(input).toHaveValue('test_password')
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
})
})

View File

@@ -0,0 +1,417 @@
import { expect } from '@playwright/test'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import type { components as RegistryComponents } from '@comfyorg/registry-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
type InstalledPacksResponse =
ManagerComponents['schemas']['InstalledPacksResponse']
type RegistryNodePack = RegistryComponents['schemas']['Node']
interface AlgoliaSearchResult {
hits: Partial<AlgoliaNodePack>[]
nbHits: number
page: number
nbPages: number
hitsPerPage: number
}
interface AlgoliaSearchResponse {
results: AlgoliaSearchResult[]
}
const MOCK_PACK_A: RegistryNodePack = {
id: 'test-pack-a',
name: 'Test Pack A',
description: 'A test custom node pack',
downloads: 5000,
status: 'NodeStatusActive',
publisher: { id: 'test-publisher', name: 'Test Publisher' },
latest_version: { version: '1.0.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-a',
tags: ['image', 'processing']
}
const MOCK_PACK_B: RegistryNodePack = {
id: 'test-pack-b',
name: 'Test Pack B',
description: 'Another test custom node pack for testing search',
downloads: 3000,
status: 'NodeStatusActive',
publisher: { id: 'another-publisher', name: 'Another Publisher' },
latest_version: { version: '2.1.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-b',
tags: ['video', 'generation']
}
const MOCK_PACK_C: RegistryNodePack = {
id: 'test-pack-c',
name: 'Test Pack C',
description: 'Third test pack',
downloads: 100,
status: 'NodeStatusActive',
publisher: { id: 'test-publisher', name: 'Test Publisher' },
latest_version: { version: '0.5.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-c'
}
const MOCK_INSTALLED_PACKS: InstalledPacksResponse = {
'test-pack-a': {
ver: '1.0.0',
cnr_id: 'test-pack-a',
enabled: true
},
'test-pack-c': {
ver: '0.5.0',
cnr_id: 'test-pack-c',
enabled: false
}
}
const MOCK_HIT_A: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-a',
id: 'test-pack-a',
name: 'Test Pack A',
description: 'A test custom node pack',
total_install: 5000,
status: 'NodeStatusActive',
publisher_id: 'test-publisher',
latest_version: '1.0.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-a',
comfy_nodes: ['TestNodeA1', 'TestNodeA2'],
create_time: '2024-01-01T00:00:00Z',
update_time: '2024-06-01T00:00:00Z',
license: 'MIT',
tags: ['image', 'processing']
}
const MOCK_HIT_B: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-b',
id: 'test-pack-b',
name: 'Test Pack B',
description: 'Another test custom node pack for testing search',
total_install: 3000,
status: 'NodeStatusActive',
publisher_id: 'another-publisher',
latest_version: '2.1.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-b',
comfy_nodes: ['TestNodeB1'],
create_time: '2024-02-01T00:00:00Z',
update_time: '2024-07-01T00:00:00Z',
license: 'Apache-2.0',
tags: ['video', 'generation']
}
const MOCK_HIT_C: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-c',
id: 'test-pack-c',
name: 'Test Pack C',
description: 'Third test pack',
total_install: 100,
status: 'NodeStatusActive',
publisher_id: 'test-publisher',
latest_version: '0.5.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-c',
comfy_nodes: ['TestNodeC1'],
create_time: '2024-03-01T00:00:00Z',
update_time: '2024-05-01T00:00:00Z',
license: 'MIT'
}
const MOCK_ALGOLIA_RESPONSE: AlgoliaSearchResponse = {
results: [
{
hits: [MOCK_HIT_A, MOCK_HIT_B, MOCK_HIT_C],
nbHits: 3,
page: 0,
nbPages: 1,
hitsPerPage: 20
}
]
}
const MOCK_ALGOLIA_PACK_B_ONLY: AlgoliaSearchResponse = {
results: [
{
hits: [MOCK_HIT_B],
nbHits: 1,
page: 0,
nbPages: 1,
hitsPerPage: 20
}
]
}
const MOCK_ALGOLIA_EMPTY: AlgoliaSearchResponse = {
results: [
{
hits: [],
nbHits: 0,
page: 0,
nbPages: 0,
hitsPerPage: 20
}
]
}
test.describe('ManagerDialog', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
const statsWithManager = {
...mockSystemStats,
system: {
...mockSystemStats.system,
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({ json: statsWithManager })
})
await comfyPage.featureFlags.mockServerFeatures({
'extension.manager.supports_v4': true
})
await comfyPage.page.route(
'**/v2/customnode/installed**',
async (route) => {
await route.fulfill({ json: MOCK_INSTALLED_PACKS })
}
)
await comfyPage.page.route(
'**/v2/manager/queue/status**',
async (route) => {
await route.fulfill({
json: {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
})
}
)
await comfyPage.page.route(
'**/v2/manager/queue/history**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
})
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
})
// Mock Comfy Registry API (fallback when Algolia credentials are unavailable)
const registryListResponse = {
total: 3,
nodes: [MOCK_PACK_A, MOCK_PACK_B, MOCK_PACK_C],
page: 1,
limit: 64,
totalPages: 1
}
await comfyPage.page.route(
'**/api.comfy.org/nodes/search**',
async (route) => {
await route.fulfill({ json: registryListResponse })
}
)
await comfyPage.page.route(
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
async (route) => {
await route.fulfill({ json: registryListResponse })
}
)
await comfyPage.page.route(
'**/v2/customnode/getmappings**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.page.route(
'**/v2/customnode/import_fail_info**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.setup()
})
async function openManagerDialog(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.OpenManagerDialog')
}
test('Opens the manager dialog via command', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
})
test('Displays pack cards from search results', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Test Pack A')).toBeVisible()
await expect(dialog.getByText('Test Pack B')).toBeVisible()
await expect(dialog.getByText('Test Pack C')).toBeVisible()
})
test('Search filters displayed packs', async ({ comfyPage }) => {
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
})
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
})
await comfyPage.page.route(
'**/api.comfy.org/nodes/search**',
async (route) => {
await route.fulfill({
json: {
total: 1,
nodes: [MOCK_PACK_B],
page: 1,
limit: 64,
totalPages: 1
}
})
}
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const searchInput = dialog.getByPlaceholder(/search/i)
await searchInput.fill('Test Pack B')
await expect(dialog.getByText('Test Pack B')).toBeVisible()
await expect(dialog.getByText('Test Pack A')).not.toBeVisible()
})
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
await comfyPage.page.route(
'**/api.comfy.org/nodes/test-pack-a',
async (route) => {
await route.fulfill({ json: MOCK_PACK_A })
}
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await dialog.getByText('Test Pack A').first().click()
await expect(dialog.getByText('Test Publisher').first()).toBeVisible()
})
test('Left side panel navigation tabs exist', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const nav = dialog.locator('nav')
await expect(nav.getByText('All Extensions')).toBeVisible()
await expect(nav.getByText('Not Installed')).toBeVisible()
await expect(nav.getByText('All Installed')).toBeVisible()
await expect(nav.getByText('Updates Available')).toBeVisible()
})
test('Switching tabs changes the content view', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const nav = dialog.locator('nav')
await nav.getByText('All Installed').click()
await expect(dialog.getByText('Test Pack A')).toBeVisible()
})
test('Closes via Escape key', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog).not.toBeVisible()
})
test('Empty search shows no results message', async ({ comfyPage }) => {
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
})
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
})
await comfyPage.page.route(
'**/api.comfy.org/nodes/search**',
async (route) => {
await route.fulfill({
json: {
total: 0,
nodes: [],
page: 1,
limit: 64,
totalPages: 0
}
})
}
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const searchInput = dialog.getByPlaceholder(/search/i)
await searchInput.fill('nonexistent-pack-xyz-999')
await expect(
dialog.getByText(/no results found|try a different search/i).first()
).toBeVisible()
})
test('Search mode can be switched between packs and nodes', async ({
comfyPage
}) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const modeSelector = dialog.getByText('Node Pack').first()
await expect(modeSelector).toBeVisible()
await modeSelector.click()
const nodesOption = comfyPage.page.getByRole('option', { name: 'Nodes' })
await expect(nodesOption).toBeVisible()
await nodesOption.click()
})
})

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { mockSystemStats } from '../../fixtures/data/systemStats'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
const MOCK_COMFYUI_VERSION = '9.99.0-e2e-test'
@@ -145,15 +145,20 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Click the PrimeVue Select to open the dropdown
await settingRow.locator('.p-select').click()
const overlay = comfyPage.page.locator('.p-select-overlay')
await expect(overlay).toBeVisible()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may re-render during search
// filtering, causing the first click to land on a stale element.
const select = settingRow.getByRole('combobox')
await expect(async () => {
const expanded = await select.getAttribute('aria-expanded')
if (expanded !== 'true') await select.click()
await expect(select).toHaveAttribute('aria-expanded', 'true')
}).toPass({ timeout: 3000 })
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
await overlay
.locator(`.p-select-option-label:text-is("${targetValue}")`)
await comfyPage.page
.getByRole('option', { name: targetValue, exact: true })
.click()
await expect

View File

@@ -1,9 +1,9 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { AssetInfo } from '../../../src/schemas/apiSchema'
import { comfyPageFixture } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import type { AssetInfo } from '@/schemas/apiSchema'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
interface PublishRecord {
workflow_id: string

View File

@@ -35,14 +35,14 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
}
)
// No DOM widget should be created by creation of interim LGraphNode objects.
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {
const initialCount = await comfyPage.getDOMWidgetCount()
const initialCount = await comfyPage.domWidgets.count()
// TextEncodeNode1
await comfyPage.page.mouse.move(618, 191)
@@ -52,7 +52,6 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
const finalCount = await comfyPage.getDOMWidgetCount()
expect(finalCount).toBe(initialCount + 1)
await expect(comfyPage.domWidgets).toHaveCount(initialCount + 1)
})
})

View File

@@ -100,8 +100,9 @@ test.describe('Error dialog', () => {
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
const reportText = await errorDialog.locator('pre').textContent()
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toBe(reportText)
await expect
.poll(async () => await getClipboardText(comfyPage.page))
.toBe(reportText)
})
test('Should open GitHub issues search when "Find Issues" is clicked', async ({

View File

@@ -47,11 +47,16 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
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 expect
.poll(() =>
comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(
`${url}/api/devtools/cleanup_fake_model`
)
return response.ok
}, comfyPage.url)
)
.toBeTruthy()
await comfyPage.workflow.loadWorkflow('missing/missing_models')
@@ -151,6 +156,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})

View File

@@ -49,18 +49,34 @@ test.describe(
const input = await comfyPage.nodeOps.getNodeRefById(3)
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.toBe('')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.toBe('')
await output1.click('title')
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect(async () => {
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
}).toPass({ timeout: 2_000 })
await expect
.poll(async () => (await input.getWidget(0)).getValue(), {
timeout: 2_000
})
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue(), {
timeout: 2_000
})
.toBe('foo')
await expect
.poll(async () => (await output2.getWidget(0)).getValue(), {
timeout: 2_000
})
.toBe('')
})
}
)

View File

@@ -40,7 +40,9 @@ test.describe('Topbar commands', () => {
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.foo))
.toBe(true)
})
test('Should not allow register command defined in other extension', async ({
@@ -60,7 +62,7 @@ test.describe('Topbar commands', () => {
})
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
expect(await menuItem.count()).toBe(0)
await expect(menuItem).toHaveCount(0)
})
test('Should allow registering keybindings', async ({ comfyPage }) => {
@@ -86,7 +88,9 @@ test.describe('Topbar commands', () => {
})
await comfyPage.page.keyboard.press('k')
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
.toBe(true)
})
test.describe('Settings', () => {
@@ -109,16 +113,20 @@ test.describe('Topbar commands', () => {
})
})
// onChange is called when the setting is first added
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
'Hello, world!'
)
await expect
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
.toBe(1)
await expect
.poll(() => comfyPage.settings.getSetting('TestSetting'))
.toBe('Hello, world!')
await comfyPage.settings.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
'Hello, universe!'
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
await expect
.poll(() => comfyPage.settings.getSetting('TestSetting'))
.toBe('Hello, universe!')
await expect
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
.toBe(2)
})
test('Should allow setting boolean settings', async ({ comfyPage }) => {
@@ -140,17 +148,21 @@ test.describe('Topbar commands', () => {
})
})
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
false
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
.toBe(false)
await expect
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
.toBe(1)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
true
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
.toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
.toBe(2)
})
test.describe('Passing through attrs to setting components', () => {
@@ -228,12 +240,15 @@ test.describe('Topbar commands', () => {
.getByText('TestSetting Test')
.locator(selector)
const isDisabled = await component.evaluate((el) =>
el.tagName === 'INPUT'
? (el as HTMLInputElement).disabled
: el.classList.contains('p-disabled')
)
expect(isDisabled).toBe(true)
await expect
.poll(() =>
component.evaluate((el) =>
el.tagName === 'INPUT'
? (el as HTMLInputElement).disabled
: el.classList.contains('p-disabled')
)
)
.toBe(true)
})
}
})
@@ -258,7 +273,7 @@ test.describe('Topbar commands', () => {
await comfyPage.settingDialog.goToAboutPanel()
const badge = comfyPage.page.locator('.about-badge').last()
expect(badge).toBeDefined()
expect(await badge.textContent()).toContain('Test Badge')
await expect(badge).toContainText('Test Badge')
})
})
@@ -276,11 +291,13 @@ test.describe('Topbar commands', () => {
})
await comfyPage.nodeOps.fillPromptDialog('Hello, world!')
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
await expect
.poll(() =>
comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
)
).toBe('Hello, world!')
.toBe('Hello, world!')
})
test('Should allow showing a confirmation dialog', async ({
@@ -298,11 +315,13 @@ test.describe('Topbar commands', () => {
})
await comfyPage.confirmDialog.click('confirm')
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
await expect
.poll(() =>
comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
)
).toBe(true)
.toBe(true)
})
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
@@ -319,11 +338,13 @@ test.describe('Topbar commands', () => {
})
await comfyPage.confirmDialog.click('reject')
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
await expect
.poll(() =>
comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
)
).toBeNull()
.toBeNull()
})
})
@@ -363,14 +384,16 @@ test.describe('Topbar commands', () => {
)
await toolboxButton.click()
expect(
await comfyPage.page.evaluate(
() =>
(window as unknown as Record<string, unknown>)[
'selectionCommandExecuted'
]
await expect
.poll(() =>
comfyPage.page.evaluate(
() =>
(window as unknown as Record<string, unknown>)[
'selectionCommandExecuted'
]
)
)
).toBe(true)
.toBe(true)
})
})
})

View File

@@ -59,31 +59,30 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
{ timeout: 10000 }
)
// Get the captured messages
const messages = await newPage.evaluate(() => window.__capturedMessages)
// Verify client sent feature flags
expect(messages!.clientFeatureFlags).toBeTruthy()
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages!.clientFeatureFlags).toHaveProperty('data')
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
'supports_preview_metadata'
)
expect(
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
).toBe('boolean')
await expect(async () => {
const flags = await newPage.evaluate(
() => window.__capturedMessages?.clientFeatureFlags
)
expect(flags).not.toBeNull()
expect(flags?.type).toBe('feature_flags')
expect(flags?.data).not.toBeNull()
expect(flags?.data).toHaveProperty('supports_preview_metadata')
expect(typeof flags?.data?.supports_preview_metadata).toBe('boolean')
}).toPass()
// Verify server sent feature flags back
expect(messages!.serverFeatureFlags).toBeTruthy()
expect(messages!.serverFeatureFlags).toHaveProperty(
'supports_preview_metadata'
)
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
'boolean'
)
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
await expect(async () => {
const flags = await newPage.evaluate(
() => window.__capturedMessages?.serverFeatureFlags
)
expect(flags).not.toBeNull()
expect(flags).toHaveProperty('supports_preview_metadata')
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
expect(flags).toHaveProperty('max_upload_size')
expect(typeof flags?.max_upload_size).toBe('number')
expect(Object.keys(flags ?? {}).length).toBeGreaterThan(0)
}).toPass()
await newPage.close()
})
@@ -91,37 +90,44 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
test('Server feature flags are received and accessible', async ({
comfyPage
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window.app!.api.serverFeatureFlags.value
})
// Verify we received real feature flags from the backend
expect(serverFlags).toBeTruthy()
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
// The backend should send feature flags
expect(serverFlags).toHaveProperty('supports_preview_metadata')
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
expect(serverFlags).toHaveProperty('max_upload_size')
expect(typeof serverFlags.max_upload_size).toBe('number')
await expect(async () => {
const flags = await comfyPage.page.evaluate(
() => window.app!.api.serverFeatureFlags.value
)
expect(flags).not.toBeNull()
expect(Object.keys(flags).length).toBeGreaterThan(0)
// The backend should send feature flags
expect(flags).toHaveProperty('supports_preview_metadata')
expect(typeof flags.supports_preview_metadata).toBe('boolean')
expect(flags).toHaveProperty('max_upload_size')
expect(typeof flags.max_upload_size).toBe('number')
}).toPass()
})
test('serverSupportsFeature method works with real backend flags', async ({
comfyPage
}) => {
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window.app!.api.serverSupportsFeature('supports_preview_metadata')
})
// The method should return a boolean based on the backend's value
expect(typeof supportsPreviewMetadata).toBe('boolean')
await expect
.poll(() =>
comfyPage.page.evaluate(
() =>
typeof window.app!.api.serverSupportsFeature(
'supports_preview_metadata'
)
)
)
.toBe('boolean')
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
})
expect(supportsNonExistent).toBe(false)
await expect
.poll(() =>
comfyPage.page.evaluate(() =>
window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
)
)
.toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
@@ -160,41 +166,51 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
comfyPage
}) => {
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
await expect
.poll(() =>
comfyPage.page.evaluate(
() =>
typeof window.app!.api.getServerFeature('supports_preview_metadata')
)
)
.toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
await expect(async () => {
const maxUpload = await comfyPage.page.evaluate(() =>
window.app!.api.getServerFeature('max_upload_size')
)
expect(typeof maxUpload).toBe('number')
expect(maxUpload as number).toBeGreaterThan(0)
}).toPass()
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeature(
'non_existent_feature_xyz',
'default'
await expect
.poll(() =>
comfyPage.page.evaluate(() =>
window.app!.api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
)
)
})
expect(defaultValue).toBe('default')
.toBe('default')
})
test('getServerFeatures returns all backend feature flags', async ({
comfyPage
}) => {
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
expect(allFeatures).toHaveProperty('supports_preview_metadata')
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
expect(allFeatures).toHaveProperty('max_upload_size')
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
await expect(async () => {
const features = await comfyPage.page.evaluate(() =>
window.app!.api.getServerFeatures()
)
expect(features).not.toBeNull()
expect(features).toHaveProperty('supports_preview_metadata')
expect(typeof features.supports_preview_metadata).toBe('boolean')
expect(features).toHaveProperty('max_upload_size')
expect(Object.keys(features).length).toBeGreaterThan(0)
}).toPass()
})
test('Client feature flags are immutable', async ({ comfyPage }) => {
@@ -324,26 +340,22 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}
)
// Get readiness state
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window.app!.api.serverFeatureFlags.value
}
})
// Verify feature flags are available
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
await expect(async () => {
const flags = await newPage.evaluate(
() => window.app!.api.serverFeatureFlags.value
)
expect(flags).toHaveProperty('supports_preview_metadata')
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
expect(flags).toHaveProperty('max_upload_size')
}).toPass()
// Verify feature flags were received (we detected them via polling)
expect(readiness.featureFlagsReceived).toBe(true)
// Verify API was initialized (feature flags require API)
expect(readiness.apiInitialized).toBe(true)
// Verify feature flags were received and API was initialized
await expect(async () => {
const readiness = await newPage.evaluate(() => window.__appReadiness)
expect(readiness?.featureFlagsReceived).toBe(true)
expect(readiness?.apiInitialized).toBe(true)
}).toPass()
await newPage.close()
})

View File

@@ -29,11 +29,9 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).toBeVisible()
})

View File

@@ -11,17 +11,19 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
test('Fix link input slots', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
expect(
await comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
).toBe(1)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
)
.toBe(1)
})
test('Validate workflow links', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
await expect(comfyPage.toast.visibleToasts).toHaveCount(2)
})
// Regression: duplicate links with shifted target_slot (widget-to-input
@@ -36,72 +38,77 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
function evaluateGraph() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraph = graph.subgraphs.values().next().value
if (!subgraph) return { error: 'No subgraph found' }
const subgraph = graph.subgraphs.values().next().value
if (!subgraph) return { error: 'No subgraph found' }
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
const switchCfg = subgraph.getNodeById(120)
const ksampler85 = subgraph.getNodeById(85)
const ksampler86 = subgraph.getNodeById(86)
if (!switchCfg || !ksampler85 || !ksampler86)
return { error: 'Required nodes not found' }
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
const switchCfg = subgraph.getNodeById(120)
const ksampler85 = subgraph.getNodeById(85)
const ksampler86 = subgraph.getNodeById(86)
if (!switchCfg || !ksampler85 || !ksampler86)
return { error: 'Required nodes not found' }
// Find cfg inputs by name (slot indices shift due to widget-to-input)
const cfgInput85 = ksampler85.inputs.find(
(i: { name: string }) => i.name === 'cfg'
// Find cfg inputs by name (slot indices shift due to widget-to-input)
const cfgInput85 = ksampler85.inputs.find(
(i: { name: string }) => i.name === 'cfg'
)
const cfgInput86 = ksampler86.inputs.find(
(i: { name: string }) => i.name === 'cfg'
)
const cfg85Linked = cfgInput85?.link != null
const cfg86Linked = cfgInput86?.link != null
// Verify the surviving links exist in the subgraph link map
const cfg85LinkValid =
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
const cfg86LinkValid =
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
let cfgLinkToNode85Count = 0
for (const link of subgraph.links.values()) {
if (link.origin_id === 120 && link.target_id === 85)
cfgLinkToNode85Count++
}
return {
cfg85Linked,
cfg86Linked,
cfg85LinkValid,
cfg86LinkValid,
cfg85LinkId: cfgInput85?.link ?? null,
cfg86LinkId: cfgInput86?.link ?? null,
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
switchOutputLinkCount,
cfgLinkToNode85Count
}
})
}
// Poll graph state once, then assert all properties
await expect(async () => {
const r = await evaluateGraph()
// Both KSamplerAdvanced nodes must have their cfg input connected
expect(r.cfg85Linked).toBe(true)
expect(r.cfg86Linked).toBe(true)
// Links must exist in the subgraph link map
expect(r.cfg85LinkValid).toBe(true)
expect(r.cfg86LinkValid).toBe(true)
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
expect(r.switchOutputLinkCount).toBe(2)
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
expect(r.cfgLinkToNode85Count).toBe(1)
// Output link IDs must match the input link IDs (source/target integrity)
expect(r.switchOutputLinkIds).toEqual(
expect.arrayContaining([r.cfg85LinkId, r.cfg86LinkId])
)
const cfgInput86 = ksampler86.inputs.find(
(i: { name: string }) => i.name === 'cfg'
)
const cfg85Linked = cfgInput85?.link != null
const cfg86Linked = cfgInput86?.link != null
// Verify the surviving links exist in the subgraph link map
const cfg85LinkValid =
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
const cfg86LinkValid =
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
let cfgLinkToNode85Count = 0
for (const link of subgraph.links.values()) {
if (link.origin_id === 120 && link.target_id === 85)
cfgLinkToNode85Count++
}
return {
cfg85Linked,
cfg86Linked,
cfg85LinkValid,
cfg86LinkValid,
cfg85LinkId: cfgInput85?.link ?? null,
cfg86LinkId: cfgInput86?.link ?? null,
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
switchOutputLinkCount,
cfgLinkToNode85Count
}
})
expect(result).not.toHaveProperty('error')
// Both KSamplerAdvanced nodes must have their cfg input connected
expect(result.cfg85Linked).toBe(true)
expect(result.cfg86Linked).toBe(true)
// Links must exist in the subgraph link map
expect(result.cfg85LinkValid).toBe(true)
expect(result.cfg86LinkValid).toBe(true)
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
expect(result.switchOutputLinkCount).toBe(2)
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
expect(result.cfgLinkToNode85Count).toBe(1)
// Output link IDs must match the input link IDs (source/target integrity)
expect(result.switchOutputLinkIds).toEqual(
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
)
}).toPass()
})
})

View File

@@ -31,18 +31,18 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window.LiteGraph!.HIDDEN_LINK
})
expect(await comfyPage.settings.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.LinkRenderMode'))
.toBe(hiddenLinkRenderMode)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
expect(
await comfyPage.settings.getSetting('Comfy.LinkRenderMode')
).not.toBe(hiddenLinkRenderMode)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.LinkRenderMode'))
.not.toBe(hiddenLinkRenderMode)
}
)
@@ -92,7 +92,6 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
// Click backdrop to close
const backdrop = comfyPage.page.locator('.fixed.inset-0').first()
await backdrop.click()
await comfyPage.nextFrame()
// Modal should be hidden
await expect(zoomModal).not.toBeVisible()

View File

@@ -28,17 +28,20 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const positions = await comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
const getGroupPositions = () =>
comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
expect(positions).toHaveLength(2)
const dx = Math.abs(positions[0].x - positions[1].x)
const dy = Math.abs(positions[0].y - positions[1].y)
expect(dx).toBeCloseTo(50, 0)
expect(dy).toBeCloseTo(15, 0)
await expect.poll(getGroupPositions).toHaveLength(2)
await expect(async () => {
const positions = await getGroupPositions()
expect(Math.abs(positions[0].x - positions[1].x)).toBeCloseTo(50, 0)
expect(Math.abs(positions[0].y - positions[1].y)).toBeCloseTo(15, 0)
}).toPass({ timeout: 5000 })
})
})

View File

@@ -1,9 +1,10 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
import { TestIds } from '@e2e/fixtures/selectors'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
@@ -32,7 +33,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
expect(await libraryTab.getFolder(groupNodeCategory).count()).toBe(1)
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
})
test('Can be added to canvas using node library sidebar', async ({
@@ -45,9 +46,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
await libraryTab.getNode(groupNodeName).click()
// Verify the node is added to the canvas
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialNodeCount + 1
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount + 1)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
@@ -58,11 +59,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
.click()
// Verify the node is added to the bookmarks tab
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([groupNodeBookmarkName])
await expect
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toEqual([groupNodeBookmarkName])
// Verify the bookmark node with the same name is added to the tree
expect(await libraryTab.getNode(groupNodeName).count()).not.toBe(0)
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
// Unbookmark the node
await libraryTab
@@ -72,9 +75,11 @@ test.describe('Group Node', { tag: '@node' }, () => {
.click()
// Verify the node is removed from the bookmarks tab
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toHaveLength(0)
await expect
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toHaveLength(0)
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
@@ -84,9 +89,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
.locator('.bookmark-button')
.click()
await comfyPage.page.hover('.p-tree-node-label.tree-explorer-node-label')
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(
true
)
await expect(
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
await libraryTab
.getNode(groupNodeName)
.locator('.bookmark-button')
@@ -147,12 +152,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
const manage1 = await group1.manageGroupNode()
await comfyPage.nextFrame()
expect(await manage1.getSelectedNodeType()).toBe('g1')
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
await manage1.close()
await expect(manage1.root).not.toBeVisible()
const manage2 = await group2.manageGroupNode()
expect(await manage2.getSelectedNodeType()).toBe('g2')
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
})
test('Preserves hidden input configuration when containing duplicate node types', async ({
@@ -166,24 +171,31 @@ test.describe('Group Node', { tag: '@node' }, () => {
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'
const totalInputCount = await comfyPage.page.evaluate((nodeName) => {
const {
extra: { groupNodes }
} = window.app!.graph!
const { nodes } = groupNodes![nodeName]
return nodes.reduce((acc, node) => acc + (node.inputs?.length ?? 0), 0)
}, groupNodeName)
const visibleInputCount = await comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node!.inputs.length
}, groupNodeId)
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
expect(totalInputCount).toBe(4)
await expect
.poll(() =>
comfyPage.page.evaluate((nodeName) => {
const {
extra: { groupNodes }
} = window.app!.graph!
const { nodes } = groupNodes![nodeName]
return nodes.reduce(
(acc, node) => acc + (node.inputs?.length ?? 0),
0
)
}, groupNodeName)
)
.toBe(4)
// Verify there are 2 visible inputs (2 have been hidden in config)
expect(visibleInputCount).toBe(2)
await expect
.poll(() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node!.inputs.length
}, groupNodeId)
)
.toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
@@ -210,7 +222,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
// Connect node to group
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
const input = await ckpt.connectOutput(0, groupNode, 0)
expect(await input.getLinkCount()).toBe(1)
await expect.poll(() => input.getLinkCount()).toBe(1)
// Modify the group node via manage dialog
const manage = await groupNode.manageGroupNode()
await manage.selectNode('KSampler')
@@ -219,14 +231,14 @@ test.describe('Group Node', { tag: '@node' }, () => {
await manage.save()
await manage.close()
// Ensure the link is still present
expect(await input.getLinkCount()).toBe(1)
await expect.poll(() => input.getLinkCount()).toBe(1)
})
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
@@ -261,8 +273,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)
expect(await isRegisteredLitegraph(comfyPage)).toBe(true)
expect(await isRegisteredNodeDefStore(comfyPage)).toBe(true)
await expect.poll(() => isRegisteredLitegraph(comfyPage)).toBe(true)
await expect.poll(() => isRegisteredNodeDefStore(comfyPage)).toBe(true)
}
test.beforeEach(async ({ comfyPage }) => {
@@ -333,18 +345,18 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.page.keyboard.press('Alt+g')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Alt+g')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
})
})

View File

@@ -66,11 +66,14 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
// Outer Group + Inner Group + 1 node = 3 items
expect(counts.selectedItemCount).toBe(3)
expect(counts.selectedGroupCount).toBe(2)
expect(counts.selectedNodeCount).toBe(1)
await expect
.poll(() => getSelectionCounts(comfyPage))
.toMatchObject({
selectedItemCount: 3,
selectedGroupCount: 2,
selectedNodeCount: 1
})
})
test('Setting disabled: clicking outer group selects only the group', async ({
@@ -87,10 +90,13 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(1)
expect(counts.selectedGroupCount).toBe(1)
expect(counts.selectedNodeCount).toBe(0)
await expect
.poll(() => getSelectionCounts(comfyPage))
.toMatchObject({
selectedItemCount: 1,
selectedGroupCount: 1,
selectedNodeCount: 0
})
})
test('Deselecting outer group deselects all children', async ({
@@ -108,8 +114,9 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
let counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(3)
await expect
.poll(() => getSelectionCounts(comfyPage))
.toMatchObject({ selectedItemCount: 3 })
// Deselect all via page.evaluate to avoid UI overlay interception
await comfyPage.page.evaluate(() => {
@@ -117,7 +124,8 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
})
await comfyPage.nextFrame()
counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(0)
await expect
.poll(() => getSelectionCounts(comfyPage))
.toMatchObject({ selectedItemCount: 0 })
})
})

View File

@@ -11,8 +11,8 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBe(2)
// Copy the KSampler node (puts data-metadata in clipboard)
const ksamplerNodes =
@@ -51,8 +51,9 @@ test.describe(
// Node count should remain the same — stale node metadata should NOT
// be deserialized when a media node is selected.
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(finalCount).toBe(initialCount)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
}
)

View File

@@ -62,9 +62,9 @@ test.describe('Node Interaction', () => {
for (const node of clipNodes) {
await node.click('title', { modifiers: [modifier] })
}
const selectedNodeCount =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedNodeCount).toBe(clipNodes.length)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(clipNodes.length)
})
})
@@ -111,9 +111,9 @@ test.describe('Node Interaction', () => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await dragSelectNodes(comfyPage, clipNodes)
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(clipNodes.length)
})
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
@@ -243,6 +243,7 @@ test.describe('Node Interaction', () => {
}) => {
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
@@ -327,18 +328,42 @@ test.describe('Node Interaction', () => {
'Can toggle dom widget node open/closed',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
// Find the node whose collapse toggler matches the hardcoded position.
// getNodeRefsByType order is non-deterministic, so identify by proximity.
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const togglerPos = DefaultGraphPositions.textEncodeNodeToggler
let targetNode = nodes[0]
let minDist = Infinity
for (const n of nodes) {
const pos = await n.getPosition()
const dist = Math.hypot(pos.x - togglerPos.x, pos.y - togglerPos.y)
if (dist < minDist) {
minDist = dist
targetNode = n
}
}
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNodeToggler
position: togglerPos
})
await comfyPage.nextFrame()
await expect.poll(() => targetNode.isCollapsed()).toBe(true)
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.delay(1000)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNodeToggler
})
// Re-expand: clicking the canvas toggler on a collapsed node is
// unreliable because DOM widget overlays may intercept the pointer
// event. Use programmatic collapse() for the expand step.
// TODO(#11006): Restore click-to-expand once DOM widget overlay pointer interception is fixed
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)!
node.collapse()
window.app!.canvas.setDirty(true, true)
}, targetNode.id)
await comfyPage.nextFrame()
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
// Move mouse away to avoid hover highlight differences.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
@@ -358,14 +383,17 @@ test.describe('Node Interaction', () => {
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
await comfyPage.delay(300)
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
// LiteGraph's graphdialog has a 256ms dismiss guard (Date.now() - clickTime > 256).
// Retry the canvas click until the dialog actually closes.
await expect(async () => {
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden({ timeout: 500 })
}).toPass({ timeout: 5000 })
})
test('Can close prompt dialog with canvas click (text widget)', async ({
@@ -381,14 +409,17 @@ test.describe('Node Interaction', () => {
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
await comfyPage.delay(300)
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
// LiteGraph's graphdialog has a 256ms dismiss guard (Date.now() - clickTime > 256).
// Retry the canvas click until the dialog actually closes.
await expect(async () => {
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden({ timeout: 500 })
}).toPass({ timeout: 5000 })
})
test(
@@ -420,7 +451,7 @@ test.describe('Node Interaction', () => {
},
delay: 5
})
expect(await comfyPage.page.locator('.node-title-editor').count()).toBe(0)
await expect(comfyPage.page.locator('.node-title-editor')).toHaveCount(0)
})
test(
@@ -450,10 +481,31 @@ test.describe('Node Interaction', () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const group = window.app!.graph.groups[0]
return group ? [group.size[0], group.size[1]] : null
})
)
.not.toBeNull()
const initialGroupSize = await comfyPage.page.evaluate(() => {
const group = window.app!.graph.groups[0]
return group ? [group.size[0], group.size[1]] : null
})
await comfyPage.keyboard.selectAll()
await comfyPage.nextFrame()
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const group = window.app!.graph.groups[0]
return group ? [group.size[0], group.size[1]] : null
})
)
.not.toEqual(initialGroupSize)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-fit-to-contents.png'
)
@@ -462,15 +514,19 @@ test.describe('Node Interaction', () => {
test('Can pin/unpin nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
)[0]
await comfyPage.command.executeCommand(
'Comfy.Canvas.ToggleSelectedNodes.Pin'
)
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isPinned()).toBe(true)
await expect(comfyPage.canvas).toHaveScreenshot('nodes-pinned.png')
await comfyPage.command.executeCommand(
'Comfy.Canvas.ToggleSelectedNodes.Pin'
)
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isPinned()).toBe(false)
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
})
@@ -479,11 +535,15 @@ test.describe('Node Interaction', () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
)[0]
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
}
)
@@ -582,23 +642,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
}
await comfyPage.page.mouse.move(10, 10)
expect(await getCursorStyle()).toBe('default')
await expect.poll(() => getCursorStyle()).toBe('default')
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await expect.poll(() => getCursorStyle()).toBe('grabbing')
// Move mouse should not alter cursor style.
await comfyPage.page.mouse.move(10, 20)
expect(await getCursorStyle()).toBe('grabbing')
await expect.poll(() => getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
expect(await getCursorStyle()).toBe('default')
await expect.poll(() => getCursorStyle()).toBe('default')
await comfyPage.page.keyboard.down('Space')
expect(await getCursorStyle()).toBe('grab')
await expect.poll(() => getCursorStyle()).toBe('grab')
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await expect.poll(() => getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
expect(await getCursorStyle()).toBe('grab')
await expect.poll(() => getCursorStyle()).toBe('grab')
await comfyPage.page.keyboard.up('Space')
expect(await getCursorStyle()).toBe('default')
await expect.poll(() => getCursorStyle()).toBe('default')
})
// https://github.com/Comfy-Org/litegraph.js/pull/424
@@ -615,27 +675,27 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
// Initial state check
await comfyPage.page.mouse.move(10, 10)
expect(await getCursorStyle()).toBe('default')
await expect.poll(() => getCursorStyle()).toBe('default')
// Click and hold
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await expect.poll(() => getCursorStyle()).toBe('grabbing')
// Press space while holding click
await comfyPage.page.keyboard.down('Space')
expect(await getCursorStyle()).toBe('grabbing')
await expect.poll(() => getCursorStyle()).toBe('grabbing')
// Release click while space is still down
await comfyPage.page.mouse.up()
expect(await getCursorStyle()).toBe('grab')
await expect.poll(() => getCursorStyle()).toBe('grab')
// Release space
await comfyPage.page.keyboard.up('Space')
expect(await getCursorStyle()).toBe('default')
await expect.poll(() => getCursorStyle()).toBe('default')
// Move mouse - cursor should remain default
await comfyPage.page.mouse.move(20, 20)
expect(await getCursorStyle()).toBe('default')
await expect.poll(() => getCursorStyle()).toBe('default')
})
test('Can pan when dragging a link', async ({ comfyPage, comfyMouse }) => {
@@ -726,11 +786,14 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false)
await comfyPage.setup()
const openCount = await comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.openWorkflows.length
})
expect(openCount).toBeGreaterThanOrEqual(1)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.openWorkflows.length
})
)
.toBeGreaterThanOrEqual(1)
})
test('Restore workflow on reload (switch workflow)', async ({
@@ -816,14 +879,22 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
await expect
.poll(() => comfyPage.menu.topbar.getTabNames(), { timeout: 5000 })
.poll(() => comfyPage.menu.topbar.getTabNames())
.toEqual(expect.arrayContaining([workflowA, workflowB]))
const tabs = await comfyPage.menu.topbar.getTabNames()
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
await expect
.poll(async () => {
const tabs = await comfyPage.menu.topbar.getTabNames()
return (
tabs.indexOf(workflowA) < tabs.indexOf(workflowB) &&
tabs.indexOf(workflowA) >= 0
)
})
.toBe(true)
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(activeWorkflowName).toEqual(workflowB)
await expect(comfyPage.menu.topbar.getActiveTab()).toContainText(
workflowB
)
})
test('Restores sidebar workflows after reload', async ({ comfyPage }) => {
@@ -832,17 +903,18 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(expect.arrayContaining([workflowA, workflowB]))
await expect
.poll(async () => {
const ws = await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
return ws.indexOf(workflowA) < ws.indexOf(workflowB)
})
.toBe(true)
await expect(comfyPage.menu.workflowsTab.activeWorkflowLabel).toHaveText(
workflowB
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
@@ -892,12 +964,20 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
await expect
.poll(async () => {
const tabs = await comfyPage.menu.topbar.getTabNames()
return (
tabs.includes(workflowA) &&
tabs.includes(workflowB) &&
tabs.indexOf(workflowA) < tabs.indexOf(workflowB)
)
})
.toBe(true)
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
await expect(comfyPage.menu.topbar.getActiveTab()).toContainText(
workflowB
)
})
test('Restores sidebar workflows after browser restart', async ({
@@ -908,17 +988,18 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(expect.arrayContaining([workflowA, workflowB]))
await expect
.poll(async () => {
const ws = await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
return ws.indexOf(workflowA) < ws.indexOf(workflowB)
})
.toBe(true)
await expect(comfyPage.menu.workflowsTab.activeWorkflowLabel).toHaveText(
workflowB
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
@@ -944,7 +1025,7 @@ test.describe('Load duplicate workflow', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
})
})
@@ -1075,8 +1156,9 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-click-node-select.png'
)
@@ -1111,8 +1193,9 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
}
)
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCount).toBe(clipNodes.length)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(clipNodes.length)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-left-drag-select.png'
)
@@ -1155,8 +1238,9 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-click-node-select.png'
)
@@ -1197,14 +1281,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
}
)
const selectedCountAfterDrag =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCountAfterDrag).toBeGreaterThan(0)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBeGreaterThan(0)
await comfyPage.canvasOps.clickEmptySpace()
const selectedCountAfterClear =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCountAfterClear).toBe(0)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(0)
await comfyPage.page.keyboard.down('Space')
await comfyPage.canvasOps.dragAndDrop(
@@ -1219,9 +1303,9 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
)
await comfyPage.page.keyboard.up('Space')
const selectedCountAfterSpaceDrag =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
expect(selectedCountAfterSpaceDrag).toBe(0)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(0)
})
})
@@ -1305,7 +1389,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
)
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await expect.poll(() => getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -83,9 +83,10 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
)
await action.click()
const settingAfter = await comfyPage.settings.getSetting<boolean>(
'Comfy.Queue.ShowRunProgressBar'
)
expect(settingAfter).toBe(!settingBefore)
await expect
.poll(() =>
comfyPage.settings.getSetting<boolean>('Comfy.Queue.ShowRunProgressBar')
)
.toBe(!settingBefore)
})
})

View File

@@ -18,9 +18,9 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
await textBox.click()
await textBox.fill('k')
await expect(textBox).toHaveValue('k')
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(
undefined
)
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
.toBe(undefined)
})
test('Should not trigger modifier keybinding when typing in input fields', async ({
@@ -35,7 +35,9 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
await textBox.fill('q')
await textBox.press('Control+k')
await expect(textBox).toHaveValue('q')
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
.toBe(true)
})
test('Should not trigger keybinding reserved by text input when typing in input fields', async ({
@@ -49,8 +51,8 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
await textBox.click()
await textBox.press('Control+v')
await expect(textBox).toBeFocused()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(
undefined
)
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
.toBe(undefined)
})
})

View File

@@ -1,5 +1,8 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
export class Load3DHelper {
constructor(readonly node: Locator) {}
@@ -19,6 +22,10 @@ export class Load3DHelper {
return this.node.locator('input[type="color"]')
}
get openViewerButton(): Locator {
return this.node.getByRole('button', { name: /open in 3d viewer/i })
}
getUploadButton(label: string): Locator {
return this.node.getByText(label)
}
@@ -37,4 +44,10 @@ export class Load3DHelper {
el.dispatchEvent(new Event('input', { bubbles: true }))
}, hex)
}
async waitForModelLoaded(): Promise<void> {
await expect(this.node.getByTestId(TestIds.loading.overlay)).toBeHidden({
timeout: 30000
})
}
}

View File

@@ -0,0 +1,30 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class Load3DViewerHelper {
readonly dialog: Locator
constructor(readonly page: Page) {
this.dialog = page.locator('[aria-labelledby="global-load3d-viewer"]')
}
get canvas(): Locator {
return this.dialog.locator('canvas')
}
get sidebar(): Locator {
return this.dialog.getByTestId('load3d-viewer-sidebar')
}
get cancelButton(): Locator {
return this.dialog.getByRole('button', { name: /cancel/i })
}
async waitForOpen(): Promise<void> {
await expect(this.dialog).toBeVisible({ timeout: 10000 })
}
async waitForClosed(): Promise<void> {
await expect(this.dialog).toBeHidden({ timeout: 5000 })
}
}

View File

@@ -1,31 +1,29 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { Load3DHelper } from './Load3DHelper'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
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 () => {
async ({ load3d }) => {
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
.poll(async () => {
const b = await load3d.canvas.boundingBox()
return b?.width ?? 0
})
.toBeGreaterThan(0)
await expect
.poll(async () => {
const b = await load3d.canvas.boundingBox()
return b?.height ?? 0
})
.toBeGreaterThan(0)
await expect(load3d.getUploadButton('upload 3d model')).toBeVisible()
await expect(
@@ -44,7 +42,7 @@ test.describe('Load3D', () => {
test(
'Controls menu opens and shows all categories',
{ tag: ['@smoke', '@screenshot'] },
async () => {
async ({ load3d }) => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
@@ -63,7 +61,7 @@ test.describe('Load3D', () => {
test(
'Changing background color updates the scene',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
async ({ comfyPage, load3d }) => {
await load3d.setBackgroundColor('#cc3333')
await comfyPage.nextFrame()
@@ -90,8 +88,72 @@ test.describe('Load3D', () => {
test(
'Recording controls are visible for Load3D',
{ tag: '@smoke' },
async () => {
async ({ load3d }) => {
await expect(load3d.recordingButton).toBeVisible()
}
)
test(
'Uploads a 3D model via button and renders it',
{ tag: ['@screenshot'] },
async ({ comfyPage, load3d }) => {
const uploadResponsePromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 15000 }
)
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await load3d.getUploadButton('upload 3d model').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(assetPath('cube.obj'))
await uploadResponsePromise
const node = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await node.getWidget(0)
await expect
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
'load3d-uploaded-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)
}
)
test(
'Drag-and-drops a 3D model onto the canvas',
{ tag: ['@screenshot'] },
async ({ comfyPage, load3d }) => {
const canvasBox = await load3d.canvas.boundingBox()
expect(canvasBox, 'Canvas bounding box should exist').not.toBeNull()
const dropPosition = {
x: canvasBox!.x + canvasBox!.width / 2,
y: canvasBox!.y + canvasBox!.height / 2
}
await comfyPage.dragDrop.dragAndDropFile('cube.obj', {
dropPosition,
waitForUpload: true
})
const node = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await node.getWidget(0)
await expect
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
'load3d-dropped-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,56 @@
import { expect } from '@playwright/test'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { load3dViewerTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
test.describe('Load3D Viewer', () => {
test.beforeEach(async ({ comfyPage, load3d }) => {
// Upload cube.obj so the node has a model loaded
const uploadResponsePromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 15000 }
)
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await load3d.getUploadButton('upload 3d model').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(assetPath('cube.obj'))
await uploadResponsePromise
const nodeRef = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await nodeRef.getWidget(0)
await expect
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
.toContain('cube.obj')
await load3d.waitForModelLoaded()
})
test(
'Opens viewer dialog with canvas and controls sidebar',
{ tag: '@smoke' },
async ({ load3d, viewer }) => {
await load3d.openViewerButton.click()
await viewer.waitForOpen()
await expect(viewer.canvas).toBeVisible()
const canvasBox = await viewer.canvas.boundingBox()
expect(canvasBox!.width).toBeGreaterThan(0)
expect(canvasBox!.height).toBeGreaterThan(0)
await expect(viewer.sidebar).toBeVisible()
await expect(viewer.cancelButton).toBeVisible()
}
)
test(
'Cancel button closes the viewer dialog',
{ tag: '@smoke' },
async ({ load3d, viewer }) => {
await load3d.openViewerButton.click()
await viewer.waitForOpen()
await viewer.cancelButton.click()
await viewer.waitForClosed()
}
)
})

View File

@@ -0,0 +1,171 @@
import { expect } from '@playwright/test'
import {
cameraStatesClose,
preview3dPipelineTest as test,
Preview3DPipelineContext,
waitForAppBootstrapped
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
import { assetPath } from '@e2e/fixtures/utils/paths'
async function seedLoad3dWithCubeObj(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await load3d.getUploadButton('upload 3d model').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(assetPath('cube.obj'))
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(Preview3DPipelineContext.loadNodeId),
{ timeout: 15_000 }
)
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
}
async function alignPreview3dWorkflowUiSettings(
pipeline: Preview3DPipelineContext
): Promise<void> {
await pipeline.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await pipeline.comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
}
test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
test('Preview3D loads model from execution output', async ({
preview3dPipeline: pipeline
}) => {
test.setTimeout(120_000)
await seedLoad3dWithCubeObj(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
),
{ timeout: 90_000 }
)
.not.toBe('')
const modelPath = await pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
)
await expect(
modelPath.length,
'Preview3D model path populated'
).toBeGreaterThan(4)
await expect
.poll(
() =>
pipeline.getLastTimeModelFile(Preview3DPipelineContext.previewNodeId),
{ timeout: 5000 }
)
.toBe(modelPath)
await pipeline.preview3d.waitForModelLoaded()
await expect
.poll(async () => {
const b = await pipeline.preview3d.canvas.boundingBox()
return (b?.width ?? 0) > 0 && (b?.height ?? 0) > 0
})
.toBe(true)
})
test('Preview3D restores last model and camera after save and full reload', async ({
preview3dPipeline: pipeline
}) => {
test.setTimeout(180_000)
await seedLoad3dWithCubeObj(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
),
{ timeout: 90_000 }
)
.not.toBe('')
await pipeline.preview3d.waitForModelLoaded()
const savedPath = await pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
)
const savedCamera = await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
)
expect(savedCamera, 'Camera state present after execution').not.toBeNull()
const workflowName = `p3d-restore-${Date.now().toString(36)}`
await pipeline.comfyPage.menu.workflowsTab.open()
await pipeline.comfyPage.menu.topbar.saveWorkflow(workflowName)
await pipeline.comfyPage.page.reload({ waitUntil: 'networkidle' })
await waitForAppBootstrapped(pipeline.comfyPage)
await alignPreview3dWorkflowUiSettings(pipeline)
const tab = pipeline.comfyPage.menu.workflowsTab
await tab.open()
await tab.getPersistedItem(workflowName).click()
await pipeline.comfyPage.workflow.waitForWorkflowIdle(15_000)
await pipeline.comfyPage.vueNodes.waitForNodes()
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
),
{ timeout: 30_000 }
)
.toBe(savedPath)
await expect
.poll(
() =>
pipeline.getLastTimeModelFile(Preview3DPipelineContext.previewNodeId),
{ timeout: 5000 }
)
.toBe(savedPath)
await pipeline.preview3d.waitForModelLoaded()
await expect
.poll(async () => {
const b = await pipeline.preview3d.canvas.boundingBox()
return (b?.width ?? 0) > 0 && (b?.height ?? 0) > 0
})
.toBe(true)
await expect
.poll(async () =>
cameraStatesClose(
await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
),
savedCamera,
2e-2
)
)
.toBe(true)
})
})

View File

@@ -60,11 +60,17 @@ test.describe(
test(`Load workflow from URL ${url} (drop from different browser tabs)`, async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropURL(url)
const readableName = url.split('/').pop()
await expect(comfyPage.canvas).toHaveScreenshot(
`dropped_workflow_url_${readableName}.png`
)
// The drop triggers an async fetch → parse → loadGraphData chain.
// Poll until the graph settles with the loaded workflow's nodes.
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 15000
})
.toBeGreaterThan(initialNodeCount)
})
})
@@ -84,12 +90,12 @@ test.describe(
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
const box = await node.boundingBox()
expect(box).not.toBeNull()
await expect.poll(() => node.boundingBox()).toBeTruthy()
const box = (await node.boundingBox())!
const dropPosition = {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
@@ -103,8 +109,9 @@ test.describe(
{ timeout: 10000 }
)
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newNodeCount).not.toBe(initialNodeCount)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.not.toBe(initialNodeCount)
})
}
)

View File

@@ -13,6 +13,14 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
// Load a workflow with some nodes to render
await comfyPage.workflow.loadWorkflow('default')
// Should start at normal zoom (not low quality)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
.toBe(false)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
.toBeCloseTo(1, 1)
// Get initial LOD state and settings
const initialState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
@@ -23,62 +31,55 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
}
})
// Should start at normal zoom (not low quality)
expect(initialState.lowQuality).toBe(false)
expect(initialState.scale).toBeCloseTo(1, 1)
// Calculate expected threshold (8px / 14px ≈ 0.571)
const expectedThreshold = initialState.minFontSize / 14
// Can't access private _lowQualityZoomThreshold directly
// Zoom out just above threshold (should still be high quality)
await comfyPage.canvasOps.zoom(120, 5) // Zoom out 5 steps
await comfyPage.nextFrame()
const aboveThresholdState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
// If still above threshold, should be high quality
if (aboveThresholdState.scale > expectedThreshold) {
expect(aboveThresholdState.lowQuality).toBe(false)
}
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return { lowQuality: canvas.low_quality, scale: canvas.ds.scale }
})
)
.toMatchObject({ lowQuality: false })
// Zoom out more to trigger LOD (below threshold)
await comfyPage.canvasOps.zoom(120, 5) // Zoom out 5 more steps
await comfyPage.nextFrame()
// Check that LOD is now active
const zoomedOutState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return { lowQuality: canvas.low_quality, scale: canvas.ds.scale }
})
)
.toMatchObject({ lowQuality: true })
expect(zoomedOutState.scale).toBeLessThan(expectedThreshold)
expect(zoomedOutState.lowQuality).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
.toBeLessThan(expectedThreshold)
// Zoom back in to disable LOD (above threshold)
await comfyPage.canvasOps.zoom(-120, 15) // Zoom in 15 steps
await comfyPage.nextFrame()
// Check that LOD is now inactive
const zoomedInState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return { lowQuality: canvas.low_quality, scale: canvas.ds.scale }
})
)
.toMatchObject({ lowQuality: false })
expect(zoomedInState.scale).toBeGreaterThan(expectedThreshold)
expect(zoomedInState.lowQuality).toBe(false)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
.toBeGreaterThan(expectedThreshold)
})
test('Should update threshold when font size setting changes', async ({
@@ -93,36 +94,28 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
)
// Check that font size updated
const newState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
minFontSize: canvas.min_font_size_for_lod
}
})
expect(newState.minFontSize).toBe(14)
// Expected threshold would be 14px / 14px = 1.0
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.min_font_size_for_lod)
)
.toBe(14)
// At default zoom, LOD should still be inactive (scale is exactly 1.0, not less than)
const lodState = await comfyPage.page.evaluate(() => {
return window.app!.canvas.low_quality
})
expect(lodState).toBe(false)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
.toBe(false)
// Zoom out slightly to trigger LOD
await comfyPage.canvasOps.zoom(120, 1) // Zoom out 1 step
await comfyPage.nextFrame()
const afterZoom = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
.toBe(true)
expect(afterZoom.scale).toBeLessThan(1.0)
expect(afterZoom.lowQuality).toBe(true)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
.toBeLessThan(1.0)
})
test('Should disable LOD when font size is set to 0', async ({
@@ -138,18 +131,19 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
await comfyPage.nextFrame()
// LOD should remain disabled even at very low zoom
const state = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale,
minFontSize: canvas.min_font_size_for_lod
}
})
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.min_font_size_for_lod)
)
.toBe(0)
expect(state.minFontSize).toBe(0) // LOD disabled
expect(state.lowQuality).toBe(false)
expect(state.scale).toBeLessThan(0.2) // Very zoomed out
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
.toBe(false)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
.toBeLessThan(0.2) // Very zoomed out
})
test(
@@ -169,41 +163,39 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
}, targetZoom)
await comfyPage.nextFrame()
// Wait for LOD to activate before taking screenshot
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.low_quality)
)
.toBe(true)
// Take snapshot with LOD active (default 8px setting)
await expect(comfyPage.canvas).toHaveScreenshot(
'lod-comparison-low-quality.png'
)
const lowQualityState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
expect(lowQualityState.lowQuality).toBe(true)
// Disable LOD to see high quality at same zoom
await comfyPage.settings.setSetting(
'LiteGraph.Canvas.MinFontSizeForLOD',
0
)
await comfyPage.nextFrame()
// Wait for LOD to deactivate after setting change
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.low_quality)
)
.toBe(false)
// Take snapshot with LOD disabled (full quality at same zoom)
await expect(comfyPage.canvas).toHaveScreenshot(
'lod-comparison-high-quality.png'
)
const highQualityState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
}
})
expect(highQualityState.lowQuality).toBe(false)
expect(highQualityState.scale).toBeCloseTo(targetZoom, 2)
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
.toBeCloseTo(targetZoom, 2)
}
)
})

View File

@@ -22,10 +22,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
}
})
})
await comfyPage.nextFrame()
const newChildrenCount = await comfyPage.menu.buttons.count()
expect(newChildrenCount).toBe(initialChildrenCount + 1)
await expect(comfyPage.menu.buttons).toHaveCount(initialChildrenCount + 1)
})
test.describe('Workflows topbar tabs', () => {
@@ -38,15 +35,17 @@ test.describe('Menu', { tag: '@ui' }, () => {
})
test('Can show opened workflows', async ({ comfyPage }) => {
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow'
])
await expect
.poll(() => comfyPage.menu.topbar.getTabNames())
.toEqual(['Unsaved Workflow'])
})
test('Can close saved-workflow tabs', async ({ comfyPage }) => {
const workflowName = `tempWorkflow-${test.info().title}`
await comfyPage.menu.topbar.saveWorkflow(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await expect
.poll(() => comfyPage.menu.topbar.getTabNames())
.toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
await expect
.poll(() => comfyPage.menu.topbar.getTabNames())
@@ -62,10 +61,11 @@ test.describe('Menu', { tag: '@ui' }, () => {
const topLevelMenuItem = comfyPage.page
.locator('a.p-menubar-item-link')
.first()
const isTextCutoff = await topLevelMenuItem.evaluate((el) => {
return el.scrollWidth > el.clientWidth
})
expect(isTextCutoff).toBe(false)
await expect
.poll(() =>
topLevelMenuItem.evaluate((el) => el.scrollWidth > el.clientWidth)
)
.toBe(false)
})
test('Clicking on active state items does not close menu', async ({
@@ -148,7 +148,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'
})
expect(await exportTag.count()).toBe(1)
await expect(exportTag).toHaveCount(1)
})
test('Can catch error when executing command', async ({ comfyPage }) => {
@@ -173,7 +173,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
})
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Can navigate Theme menu and switch between Dark and Light themes', async ({
@@ -205,7 +205,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
await expect(async () => {
await expect(menu).toBeVisible()
await expect(themeSubmenu).toBeVisible()
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
await expect(lightThemeItem.locator('.pi-check')).not.toHaveClass(
/invisible/
)
}).toPass({ timeout: 5000 })
// Screenshot with light theme active
@@ -230,9 +232,11 @@ test.describe('Menu', { tag: '@ui' }, () => {
await expect(async () => {
await expect(menu).toBeVisible()
await expect(themeItems2.submenu).toBeVisible()
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(
false
await expect(
themeItems2.darkTheme.locator('.pi-check')
).not.toHaveClass(/invisible/)
await expect(themeItems2.lightTheme.locator('.pi-check')).toHaveClass(
/invisible/
)
}).toPass({ timeout: 5000 })
@@ -256,9 +260,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
'Top'
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.UseNewMenu'))
.toBe('Top')
})
test(`Can migrate deprecated menu positions on initial load (${position})`, async ({
@@ -266,9 +270,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
await comfyPage.setup()
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
'Top'
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.UseNewMenu'))
.toBe('Top')
})
})
})

View File

@@ -53,13 +53,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimapContainer).toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
})
@@ -69,13 +65,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
})
@@ -108,6 +100,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
}
)
@@ -122,20 +115,18 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
const viewport = minimap.locator('.minimap-viewport')
await expect(viewport).toBeVisible()
const minimapBox = await minimap.boundingBox()
const viewportBox = await viewport.boundingBox()
expect(minimapBox).toBeTruthy()
expect(viewportBox).toBeTruthy()
expect(viewportBox!.width).toBeGreaterThan(0)
expect(viewportBox!.height).toBeGreaterThan(0)
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
minimapBox!.y
)
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
await expect(async () => {
const vb = await viewport.boundingBox()
const mb = await minimap.boundingBox()
expect(vb).toBeTruthy()
expect(mb).toBeTruthy()
expect(vb!.width).toBeGreaterThan(0)
expect(vb!.height).toBeGreaterThan(0)
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
}).toPass({ timeout: 5000 })
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
}

View File

@@ -10,9 +10,7 @@ test.describe(
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 5000 })
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})

View File

@@ -82,7 +82,13 @@ test.describe(
'Comfy.NodeBadge.NodeIdBadgeMode',
mode
)
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.settings.getSetting('Comfy.NodeBadge.NodeIdBadgeMode'),
{ message: 'NodeIdBadgeMode setting should be applied' }
)
.toBe(mode)
await comfyPage.canvasOps.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(
`node-badge-${mode}.png`
@@ -104,8 +110,11 @@ test.describe(
NodeBadgeMode.ShowAll
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'unknown')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'), {
message: 'ColorPalette setting should be applied'
})
.toBe('unknown')
await comfyPage.canvasOps.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-unknown-color-palette.png'
@@ -120,8 +129,11 @@ test.describe(
NodeBadgeMode.ShowAll
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'), {
message: 'ColorPalette setting should be applied'
})
.toBe('light')
await comfyPage.canvasOps.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-light-color-palette.png'

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Node context menu viewport overflow (#10824)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler toward the lower-left so the menu has limited space below it.
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()!
const centerX = viewportSize.width / 3
const centerY = viewportSize.height * 0.75
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible({ timeout: 3000 })
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
await comfyPage.nextFrame()
return menu
}
test('last menu item "Remove" is reachable via scroll', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const rootList = menu.locator(':scope > ul')
await expect
.poll(
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
{
message:
'Menu should overflow vertically so this test exercises the viewport clamp',
timeout: 3000
}
)
.toBe(true)
// "Remove" is the last item in the More Options menu.
// It must become reachable by scrolling the bounded menu list.
const removeItem = menu.getByText('Remove', { exact: true })
const didScroll = await rootList.evaluate((el) => {
const previousScrollTop = el.scrollTop
el.scrollTo({ top: el.scrollHeight })
return el.scrollTop > previousScrollTop
})
expect(didScroll).toBe(true)
await expect(removeItem).toBeVisible()
})
test('last menu item "Remove" is clickable and removes the node', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const removeItem = menu.getByText('Remove', { exact: true })
await removeItem.scrollIntoViewIfNeeded()
await removeItem.click()
await comfyPage.nextFrame()
// The node should be removed from the graph
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
.toBe(0)
})
}
)

View File

@@ -37,7 +37,7 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
test('Only optional inputs', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/only_optional_inputs')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()

View File

@@ -433,8 +433,7 @@ This is documentation for a custom node.
const imageCount = await images.count()
for (let i = 0; i < imageCount; i++) {
const img = images.nth(i)
const onError = await img.getAttribute('onerror')
expect(onError).toBeNull()
await expect(img).not.toHaveAttribute('onerror')
}
// Check that javascript: links are sanitized
@@ -442,10 +441,7 @@ This is documentation for a custom node.
const linkCount = await links.count()
for (let i = 0; i < linkCount; i++) {
const link = links.nth(i)
const href = await link.getAttribute('href')
if (href !== null) {
expect(href).not.toContain('javascript:')
}
await expect(link).not.toHaveAttribute('href', /^javascript:/i)
}
// Safe content should remain
@@ -512,8 +508,7 @@ This is English documentation.
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
// Should show some content even on error
const content = await helpPage.textContent()
expect(content).toBeTruthy()
await expect(helpPage).not.toHaveText('')
})
test('Should update help content when switching between nodes', async ({

View File

@@ -82,9 +82,7 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
const firstCard = comfyPage.page.locator('[data-node-name]').first()
await expect(firstCard).toBeVisible()
const nodeName = await firstCard.getAttribute('data-node-name')
expect(nodeName).toBeTruthy()
expect(nodeName!.length).toBeGreaterThan(0)
await expect(firstCard).toHaveAttribute('data-node-name', /.+/)
})
test('Node library can switch between all and essentials tabs', async ({

View File

@@ -31,8 +31,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount + 1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test('Can add first default result with Enter', async ({ comfyPage }) => {
@@ -49,8 +50,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount + 1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test.describe('Category navigation', () => {
@@ -81,8 +83,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const count = await searchBoxV2.results.count()
expect(count).toBeGreaterThan(0)
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
})
@@ -142,8 +143,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount + 1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
})
})

View File

@@ -41,8 +41,9 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
test('Search clears when reopening', async ({ comfyPage }) => {
@@ -75,9 +76,10 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const loaderResults = await searchBoxV2.results.allTextContents()
expect(samplingResults).not.toEqual(loaderResults)
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(samplingResults)
})
})
@@ -107,8 +109,9 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
const filteredResults = await searchBoxV2.results.allTextContents()
expect(filteredResults).not.toEqual(unfilteredResults)
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(unfilteredResults)
// Remove filter by clicking the chip delete button
await filterChip.getByTestId('chip-delete').click()

View File

@@ -39,18 +39,21 @@ test.describe('Painter', () => {
const canvas = node.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
const isEmptyBefore = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return true
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
await expect
.poll(async () =>
canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return true
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
})
)
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
})
expect(isEmptyBefore).toBe(true)
.toBe(true)
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
@@ -68,23 +71,24 @@ test.describe('Painter', () => {
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await expect(async () => {
const hasContent = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return false
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) return true
}
return false
})
expect(hasContent).toBe(true)
}).toPass()
await expect
.poll(async () =>
canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return false
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) return true
}
return false
})
)
.toBe(true)
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}

View File

@@ -327,8 +327,7 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
// Verify we actually entered the culling regime.
// isNodeTooSmall triggers when max(width, height) * scale < 4px.
// Typical nodes are ~200px wide, so scale must be < 0.02.
const scale = await comfyPage.canvasOps.getScale()
expect(scale).toBeLessThan(0.02)
await expect.poll(() => comfyPage.canvasOps.getScale()).toBeLessThan(0.02)
// Idle at extreme zoom-out — most nodes should be culled
for (let i = 0; i < 60; i++) {
@@ -358,9 +357,11 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
// Wait for the output widget to populate (execution_success)
const outputNode = await comfyPage.nodeOps.getNodeRefById(1)
await expect(async () => {
expect(await (await outputNode.getWidget(0)).getValue()).toBe('foo')
}).toPass({ timeout: 10000 })
await expect
.poll(async () => (await outputNode.getWidget(0)).getValue(), {
timeout: 10000
})
.toBe('foo')
const m = await comfyPage.perf.stopMeasuring('workflow-execution')
recordMeasurement(m)

View File

@@ -17,7 +17,6 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
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()
@@ -32,7 +31,6 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
false
)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(panel.errorsTabIcon).not.toBeVisible()
@@ -49,7 +47,6 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
@@ -58,6 +55,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
const runtimePanel = comfyPage.page.getByTestId(
TestIds.dialogs.runtimeErrorPanel
@@ -67,7 +65,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('nonexistent_query_xyz_12345')
await expect(runtimePanel).not.toBeVisible()
await expect(runtimePanel).toHaveCount(0)
})
})
})

View File

@@ -18,7 +18,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
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

View File

@@ -15,11 +15,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'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()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
})
.toBeTruthy()
})
test('Should show missing models group in errors tab', async ({

View File

@@ -15,7 +15,6 @@ test.describe('Properties panel position', () => {
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.nextFrame()
const propertiesPanel = comfyPage.page.getByTestId(
TestIds.propertiesPanel.root
@@ -25,23 +24,24 @@ test.describe('Properties panel position', () => {
await expect(propertiesPanel).toBeVisible()
await expect(sidebar).toBeVisible()
const propsBoundingBox = await propertiesPanel.boundingBox()
const sidebarBoundingBox = await sidebar.boundingBox()
await expect
.poll(async () => {
const propsBoundingBox = await propertiesPanel.boundingBox()
const sidebarBoundingBox = await sidebar.boundingBox()
expect(propsBoundingBox).not.toBeNull()
expect(sidebarBoundingBox).not.toBeNull()
if (!propsBoundingBox || !sidebarBoundingBox) return false
// Properties panel should be to the right of the sidebar
expect(propsBoundingBox!.x).toBeGreaterThan(
sidebarBoundingBox!.x + sidebarBoundingBox!.width
)
return (
propsBoundingBox.x > sidebarBoundingBox.x + sidebarBoundingBox.width
)
})
.toBe(true)
})
test('positions on the left when sidebar is on the right', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
const propertiesPanel = comfyPage.page.getByTestId(
TestIds.propertiesPanel.root
@@ -51,16 +51,18 @@ test.describe('Properties panel position', () => {
await expect(propertiesPanel).toBeVisible()
await expect(sidebar).toBeVisible()
const propsBoundingBox = await propertiesPanel.boundingBox()
const sidebarBoundingBox = await sidebar.boundingBox()
await expect
.poll(async () => {
const propsBoundingBox = await propertiesPanel.boundingBox()
const sidebarBoundingBox = await sidebar.boundingBox()
expect(propsBoundingBox).not.toBeNull()
expect(sidebarBoundingBox).not.toBeNull()
if (!propsBoundingBox || !sidebarBoundingBox) return false
// Properties panel should be to the left of the sidebar
expect(propsBoundingBox!.x + propsBoundingBox!.width).toBeLessThan(
sidebarBoundingBox!.x
)
return (
propsBoundingBox.x + propsBoundingBox.width < sidebarBoundingBox.x
)
})
.toBe(true)
})
test('close button icon updates based on sidebar location', async ({
@@ -72,7 +74,6 @@ test.describe('Properties panel position', () => {
// When sidebar is on the left, panel is on the right
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.nextFrame()
await expect(propertiesPanel).toBeVisible()
const closeButtonLeft = propertiesPanel
@@ -83,7 +84,6 @@ test.describe('Properties panel position', () => {
// When sidebar is on the right, panel is on the left
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
const closeButtonRight = propertiesPanel
.locator('button[aria-pressed]')

View File

@@ -43,7 +43,6 @@ test.describe('Properties panel - Title editing', () => {
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await expect(panel.panelTitle).toContainText('Workflow Overview')
await expect(panel.titleEditIcon).not.toBeVisible()
})

View File

@@ -30,8 +30,7 @@ test.describe('Properties panel - Workflow Overview', () => {
comfyPage
}) => {
await panel.switchToTab('Nodes')
const nodeCount = await comfyPage.nodeOps.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBeGreaterThan(0)
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
})

View File

@@ -46,11 +46,6 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
}, nodeName)
}
const waitForWidgetUpdate = async (comfyPage: ComfyPage) => {
// Force re-render to trigger first access of widget's options
await comfyPage.page.mouse.click(400, 300)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -85,9 +80,9 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
}) => {
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
expect(widgetOptions).toEqual(mockOptions)
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toEqual(mockOptions)
})
test('lazy loads options when widget is added via workflow load', async ({
@@ -96,23 +91,28 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const nodeName = 'Remote Widget Node'
await comfyPage.workflow.loadWorkflow('inputs/remote_widget')
const node = await comfyPage.page.evaluate((name) => {
return window.app!.graph!.nodes.find((node) => node.title === name)
}, nodeName)
expect(node).toBeDefined()
await expect
.poll(() =>
comfyPage.page.evaluate((name) => {
return (
window.app!.graph!.nodes.find((node) => node.title === name) !=
null
)
}, nodeName)
)
.toBe(true)
await waitForWidgetUpdate(comfyPage)
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
expect(widgetOptions).toEqual(mockOptions)
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toEqual(mockOptions)
})
test('applies query parameters from input spec', async ({ comfyPage }) => {
const nodeName = 'Remote Widget Node With Sort Query Param'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
expect(widgetOptions).not.toEqual(mockOptions)
expect(widgetOptions).toEqual([...mockOptions].sort())
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toEqual([...mockOptions].sort())
})
test('handles empty list of options', async ({ comfyPage }) => {
@@ -125,9 +125,7 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
expect(widgetOptions).toEqual([])
await expect.poll(() => getWidgetOptions(comfyPage, nodeName)).toEqual([])
})
test('falls back to default value when non-200 response', async ({
@@ -142,11 +140,9 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
const defaultValue = 'Loading...'
expect(widgetOptions).toEqual(defaultValue)
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toEqual('Loading...')
})
})
@@ -185,7 +181,6 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Select remote widget node
await comfyPage.page.keyboard.press('Control+A')
@@ -211,18 +206,18 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const nodeName = 'Remote Widget Node With 300ms Refresh'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Wait for initial options to load before capturing baseline
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toBeTruthy()
const initialOptions = await getWidgetOptions(comfyPage, nodeName)
// Click on the canvas to trigger widget refresh
await comfyPage.page.mouse.click(400, 300)
await expect(async () => {
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
expect(refreshedOptions).not.toEqual(initialOptions)
}).toPass({
timeout: 2_000
})
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.not.toEqual(initialOptions)
})
test('does not refresh when TTL is not set', async ({ comfyPage }) => {
@@ -237,7 +232,10 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Wait for initial fetch to complete
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toEqual(['test'])
// Force multiple re-renders
for (let i = 0; i < 3; i++) {
@@ -245,7 +243,7 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
await comfyPage.nextFrame()
}
expect(requestCount).toBe(1) // Should only make initial request
await expect.poll(() => requestCount, { timeout: 1000 }).toBe(1) // Should only make initial request
})
test('retries failed requests with backoff', async ({ comfyPage }) => {
@@ -260,17 +258,20 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Wait for exponential backoff retries to accumulate timestamps
// Initial canvas click to trigger widget render
await comfyPage.page.mouse.click(400, 300)
// Drive canvas redraws to let the retry scheduler fire
await expect(async () => {
await waitForWidgetUpdate(comfyPage)
await comfyPage.page.mouse.click(400, 300)
await comfyPage.nextFrame()
expect(timestamps.length).toBeGreaterThanOrEqual(3)
}).toPass({ timeout: 10000, intervals: [500, 1000, 1500] })
}).toPass({ timeout: 15000, intervals: [500, 1000, 1500] })
// Verify exponential backoff between retries
// Verify backoff: last interval should exceed first
const intervals = timestamps.slice(1).map((t, i) => t - timestamps[i])
expect(intervals[1]).toBeGreaterThan(intervals[0])
expect(intervals[intervals.length - 1]).toBeGreaterThan(intervals[0])
})
test('clicking refresh button forces a refresh', async ({ comfyPage }) => {
@@ -288,15 +289,19 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
// Trigger initial fetch when adding node to the graph
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Wait for initial options to load before capturing baseline
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toBeTruthy()
const initialOptions = await getWidgetOptions(comfyPage, nodeName)
// Click refresh button
await clickRefreshButton(comfyPage, nodeName)
// Verify refresh occurred
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
expect(refreshedOptions).not.toEqual(initialOptions)
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.not.toEqual(initialOptions)
})
test('control_after_refresh is applied after refresh', async ({
@@ -322,18 +327,18 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
// Trigger initial fetch when adding node to the graph
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Wait for initial options to load
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toBeTruthy()
// Click refresh button
await clickRefreshButton(comfyPage, nodeName)
// Verify the selected value of the widget is the first option in the refreshed list
await expect(async () => {
const refreshedValue = await getWidgetValue(comfyPage, nodeName)
expect(refreshedValue).toEqual('new first option')
}).toPass({
timeout: 2_000
})
await expect
.poll(() => getWidgetValue(comfyPage, nodeName))
.toEqual('new first option')
})
})
@@ -356,9 +361,12 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
// Add two widgets with same config
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName, 2)
await waitForWidgetUpdate(comfyPage)
// Wait for options to be populated before checking request count
await expect
.poll(() => getWidgetOptions(comfyPage, nodeName))
.toEqual(mockOptions)
expect(requestCount).toBe(1) // Should reuse cached data
await expect.poll(() => requestCount, { timeout: 1000 }).toBe(1) // Should reuse cached data
})
})
})

View File

@@ -16,10 +16,9 @@ test.describe(
await comfyPage.canvasOps.rightClick()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('loaders').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
})
@@ -28,6 +27,7 @@ test.describe(
await comfyPage.canvasOps.rightClick()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'add-group-group-added.png'
@@ -41,7 +41,7 @@ test.describe(
await comfyPage.contextMenu.clickMenuItem(
'Convert to Group Node (Deprecated)'
)
await comfyPage.nextFrame()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
@@ -63,6 +63,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-properties-panel.png'
@@ -78,6 +79,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-collapsed.png'
@@ -101,6 +103,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-collapsed-badge.png'
@@ -116,6 +119,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-bypassed.png'
@@ -132,6 +136,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
// Get EmptyLatentImage node title position dynamically (for dragging)
@@ -149,7 +154,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
'right-click-pinned-node.png'
)
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
await comfyPage.nextFrame()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'
@@ -169,7 +174,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
await comfyPage.nextFrame()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'
@@ -177,6 +182,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
// Get EmptyLatentImage node title position dynamically (for dragging)
@@ -199,6 +205,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
await comfyPage.page.keyboard.up('Control')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-nodes-pinned.png')
await comfyPage.canvas.click({
@@ -208,6 +215,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'selected-nodes-unpinned.png'
@@ -218,7 +226,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.clickContextMenuOption('Pin')
await comfyPage.nextFrame()
await comfyPage.contextMenu.waitForHidden()
await node.click('title', { button: 'right' })
await expect(
comfyPage.page.locator('.litemenu-entry:has-text("Unpin")')
@@ -227,8 +235,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
'.litemenu-entry:has-text("Clone")'
)
await cloneItem.click()
await expect(cloneItem).toHaveCount(0)
await comfyPage.nextFrame()
await comfyPage.contextMenu.waitForHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(nodeCount + 1)

View File

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

View File

@@ -59,12 +59,19 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await expect(toolboxContainer).toBeVisible()
// Verify toolbox is positioned (canvas-based positioning has different coordinates)
const boundingBox = await toolboxContainer.boundingBox()
expect(boundingBox).not.toBeNull()
await expect
.poll(async () => await toolboxContainer.boundingBox())
.not.toBeNull()
// Canvas-based positioning can vary, just verify toolbox appears in reasonable bounds
expect(boundingBox!.x).toBeGreaterThan(-200) // Not too far off-screen left
expect(boundingBox!.x).toBeLessThan(1000) // Not too far off-screen right
expect(boundingBox!.y).toBeGreaterThan(-100) // Not too far off-screen top
await expect
.poll(async () => (await toolboxContainer.boundingBox())?.x)
.toBeGreaterThan(-200) // Not too far off-screen left
await expect
.poll(async () => (await toolboxContainer.boundingBox())?.x)
.toBeLessThan(1000) // Not too far off-screen right
await expect
.poll(async () => (await toolboxContainer.boundingBox())?.y)
.toBeGreaterThan(-100) // Not too far off-screen top
})
test('hide when select and drag happen at the same time', async ({
@@ -169,7 +176,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
const selectedNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
expect(await selectedNode.getProperty('color')).not.toBeNull()
await expect.poll(() => selectedNode.getProperty('color')).toBeDefined()
})
test('color picker shows current color of selected nodes', async ({
@@ -266,7 +273,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
const selectedNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
expect(await selectedNode.getProperty('color')).toBeUndefined()
await expect.poll(() => selectedNode.getProperty('color')).toBeUndefined()
})
})
})

View File

@@ -52,10 +52,11 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 1)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.graph!._nodes.length)
)
.toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
@@ -65,8 +66,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await comfyPage.nextFrame()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -102,10 +101,11 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 2)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.graph!._nodes.length)
)
.toBe(initialCount - 2)
})
test('bypass button toggles bypass on single node', async ({ comfyPage }) => {
@@ -116,14 +116,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
expect(await nodeRef.isBypassed()).toBe(false)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
const bypassButton = comfyPage.page.getByTestId('bypass-button')
await expect(bypassButton).toBeVisible()
await bypassButton.click({ force: true })
await comfyPage.nextFrame()
expect(await nodeRef.isBypassed()).toBe(true)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, 'KSampler')).toHaveClass(
BYPASS_CLASS
)
@@ -131,7 +131,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await bypassButton.click({ force: true })
await comfyPage.nextFrame()
expect(await nodeRef.isBypassed()).toBe(false)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, 'KSampler')).not.toHaveClass(
BYPASS_CLASS
)

View File

@@ -94,7 +94,6 @@ test.describe(
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
const initialShape = await nodeRef.getProperty<number>('shape')
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).hover()
@@ -106,9 +105,7 @@ test.describe(
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
const newShape = await nodeRef.getProperty<number>('shape')
expect(newShape).not.toBe(initialShape)
expect(newShape).toBe(1)
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
})
test('changes node color via Color submenu swatch', async ({
@@ -117,9 +114,6 @@ test.describe(
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
const initialColor = await nodeRef.getProperty<string | undefined>(
'color'
)
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Color', { exact: true }).click()
@@ -128,11 +122,9 @@ test.describe(
await blueSwatch.first().click()
await comfyPage.nextFrame()
const newColor = await nodeRef.getProperty<string | undefined>('color')
expect(newColor).toBe('#223')
if (initialColor) {
expect(newColor).not.toBe(initialColor)
}
await expect
.poll(() => nodeRef.getProperty<string | undefined>('color'))
.toBe('#223')
})
test('renames a node using Rename action', async ({ comfyPage }) => {
@@ -150,8 +142,9 @@ test.describe(
await input.fill('RenamedNode')
await input.press('Enter')
await comfyPage.nextFrame()
const newTitle = await nodeRef.getProperty<string>('title')
expect(newTitle).toBe('RenamedNode')
await expect
.poll(() => nodeRef.getProperty<string>('title'))
.toBe('RenamedNode')
})
test('closes More Options menu when clicking outside', async ({

View File

@@ -176,8 +176,7 @@ test.describe('Assets sidebar - grid view display', () => {
await tab.open()
await tab.waitForAssets()
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
test('Displays imported files when switching to Imported tab', async ({
@@ -191,8 +190,7 @@ test.describe('Assets sidebar - grid view display', () => {
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
// Imported tab should show the mocked files
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
test('Displays svg outputs', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
@@ -301,10 +299,9 @@ test.describe('Assets sidebar - search', () => {
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
await expect(async () => {
const filteredCount = await tab.assetCards.count()
expect(filteredCount).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
await expect
.poll(() => tab.assetCards.count(), { timeout: 5000 })
.toBeLessThan(initialCount)
})
test('Clearing search restores all assets', async ({ comfyPage }) => {
@@ -316,9 +313,7 @@ test.describe('Assets sidebar - search', () => {
// Filter then clear
await tab.searchInput.fill('landscape')
await expect(async () => {
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
await tab.searchInput.fill('')
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
@@ -367,8 +362,7 @@ test.describe('Assets sidebar - selection', () => {
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
@@ -544,8 +538,7 @@ test.describe('Assets sidebar - context menu', () => {
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Dismiss any toasts that appeared after asset loading
await tab.dismissToasts()
@@ -623,18 +616,21 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.open()
await tab.waitForAssets()
// Select two assets
// Select the two single-output assets (job-alpha, job-beta).
// The count reflects total outputs, not cards — job-gamma has
// outputs_count: 2 which would inflate the total.
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
await cards.first().click()
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
await cards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
await cards.nth(2).click()
await comfyPage.page.keyboard.up('Control')
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
const text = await tab.selectionCountButton.textContent()
expect(text).toMatch(/Assets Selected: \d+/)
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
})
})

View File

@@ -150,12 +150,14 @@ test.describe('Model library sidebar - search', () => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
// Expand a folder and verify models are present before searching
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.leafNodes).not.toHaveCount(0, { timeout: 5000 })
await tab.searchInput.fill('nonexistent_model_xyz')
// Wait for debounce, then verify no leaf nodes
await expect
.poll(async () => await tab.leafNodes.count(), { timeout: 5000 })
.toBe(0)
await expect.poll(() => tab.leafNodes.count()).toBe(0)
})
})
@@ -238,7 +240,7 @@ test.describe('Model library sidebar - empty state', () => {
await tab.open()
await expect(tab.modelTree).toBeVisible()
expect(await tab.folderNodes.count()).toBe(0)
expect(await tab.leafNodes.count()).toBe(0)
await expect(tab.folderNodes).toHaveCount(0)
await expect(tab.leafNodes).toHaveCount(0)
})
})

View File

@@ -59,13 +59,17 @@ test.describe('Node library sidebar V2', () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
const canvasBoundingBox = await comfyPage.page
await expect
.poll(
async () => await comfyPage.page.locator('#graph-canvas').boundingBox()
)
.toBeTruthy()
const canvasBoundingBox = (await comfyPage.page
.locator('#graph-canvas')
.boundingBox()
expect(canvasBoundingBox).not.toBeNull()
.boundingBox())!
const targetPosition = {
x: canvasBoundingBox!.x + canvasBoundingBox!.width / 2,
y: canvasBoundingBox!.y + canvasBoundingBox!.height / 2
x: canvasBoundingBox.x + canvasBoundingBox.width / 2,
y: canvasBoundingBox.y + canvasBoundingBox.height / 2
}
const nodeLocator = tab.getNode('KSampler (Advanced)')
@@ -74,7 +78,7 @@ test.describe('Node library sidebar V2', () => {
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 5000 })
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
@@ -119,8 +123,6 @@ test.describe('Node library sidebar V2', () => {
// Reka UI DropdownMenuRadioItem renders with role="menuitemradio"
const options = comfyPage.page.getByRole('menuitemradio')
await expect(options.first()).toBeVisible({ timeout: 3000 })
await expect
.poll(() => options.count(), { timeout: 3000 })
.toBeGreaterThanOrEqual(2)
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
})

View File

@@ -24,10 +24,10 @@ test.describe('Sidebar splitter width independence', () => {
.locator('.p-splitter-gutter:not(.hidden)')
.first()
await expect(gutter).toBeVisible()
const box = await gutter.boundingBox()
expect(box).not.toBeNull()
const centerX = box!.x + box!.width / 2
const centerY = box!.y + box!.height / 2
await expect.poll(() => gutter.boundingBox()).not.toBeNull()
const box = (await gutter.boundingBox())!
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(centerX + deltaX, centerY, { steps: 10 })
@@ -63,12 +63,16 @@ test.describe('Sidebar splitter width independence', () => {
// Right sidebar should use its default width, not the left's resized width
const rightSidebar = comfyPage.page.locator('.side-bar-panel').first()
await expect(rightSidebar).toBeVisible()
const rightWidth = (await rightSidebar.boundingBox())!.width
// The right sidebar should NOT match the left's resized width.
// We dragged the left sidebar 100px wider, so there should be a noticeable
// difference between the left (resized) and right (default) widths.
expect(Math.abs(rightWidth - leftWidth)).toBeGreaterThan(50)
await expect
.poll(async () => {
const b = await rightSidebar.boundingBox()
return b ? Math.abs(b.width - leftWidth) : -1
})
.toBeGreaterThan(50)
})
test('localStorage keys include sidebar location', async ({ comfyPage }) => {
@@ -77,10 +81,11 @@ test.describe('Sidebar splitter width independence', () => {
await dragGutter(comfyPage, 50)
// Left-only sidebar should use the legacy key (no location suffix)
const leftKey = await comfyPage.page.evaluate(() =>
localStorage.getItem('unified-sidebar')
)
expect(leftKey).not.toBeNull()
await expect
.poll(() =>
comfyPage.page.evaluate(() => localStorage.getItem('unified-sidebar'))
)
.not.toBeNull()
// Switch to right and resize
await comfyPage.menu.nodeLibraryTab.close()
@@ -88,16 +93,20 @@ test.describe('Sidebar splitter width independence', () => {
await dragGutter(comfyPage, -50)
// Right sidebar should use a different key with location suffix
const rightKey = await comfyPage.page.evaluate(() =>
localStorage.getItem('unified-sidebar-right')
)
expect(rightKey).not.toBeNull()
await expect
.poll(() =>
comfyPage.page.evaluate(() =>
localStorage.getItem('unified-sidebar-right')
)
)
.not.toBeNull()
// Both keys should exist independently
const leftKeyStillExists = await comfyPage.page.evaluate(() =>
localStorage.getItem('unified-sidebar')
)
expect(leftKeyStillExists).not.toBeNull()
await expect
.poll(() =>
comfyPage.page.evaluate(() => localStorage.getItem('unified-sidebar'))
)
.not.toBeNull()
})
test('normalized panel sizes sum to approximately 100%', async ({
@@ -107,16 +116,33 @@ test.describe('Sidebar splitter width independence', () => {
await dragGutter(comfyPage, 80)
// Check that saved sizes sum to ~100%
const sizes = await comfyPage.page.evaluate(() => {
const raw = localStorage.getItem('unified-sidebar')
return raw ? JSON.parse(raw) : null
})
const getSidebarSizes = () =>
comfyPage.page.evaluate(() => {
const raw = localStorage.getItem('unified-sidebar')
return raw ? (JSON.parse(raw) as number[]) : null
})
expect(sizes).not.toBeNull()
expect(Array.isArray(sizes)).toBe(true)
await expect
.poll(async () => {
const sizes = await getSidebarSizes()
return Array.isArray(sizes)
})
.toBe(true)
const sum = (sizes as number[]).reduce((a, b) => a + b, 0)
expect(sum).toBeGreaterThan(99)
expect(sum).toBeLessThanOrEqual(101)
await expect
.poll(async () => {
const sizes = await getSidebarSizes()
if (!sizes) return 0
return sizes.reduce((a, b) => a + b, 0)
})
.toBeGreaterThan(99)
await expect
.poll(async () => {
const sizes = await getSidebarSizes()
if (!sizes) return Infinity
return sizes.reduce((a, b) => a + b, 0)
})
.toBeLessThanOrEqual(101)
})
})

View File

@@ -22,13 +22,14 @@ test.describe('Workflows sidebar', () => {
test('Can create new blank workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
await expect
.poll(() => tab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow'])
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow',
'*Unsaved Workflow (2)'
])
await expect
.poll(() => tab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow', '*Unsaved Workflow (2)'])
})
test('Can show top level saved workflows', async ({ comfyPage }) => {
@@ -39,39 +40,38 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1', 'workflow2'])
)
await expect
.poll(() => tab.getTopLevelSavedWorkflowNames())
.toEqual(expect.arrayContaining(['workflow1', 'workflow2']))
})
test('Can duplicate workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1')
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1'])
)
await expect
.poll(() => tab.getTopLevelSavedWorkflowNames())
.toEqual(expect.arrayContaining(['workflow1']))
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1',
'*workflow1 (Copy)'
])
await expect
.poll(() => tab.getOpenedWorkflowNames())
.toEqual(['workflow1', '*workflow1 (Copy)'])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)'
])
await expect
.poll(() => tab.getOpenedWorkflowNames())
.toEqual(['workflow1', '*workflow1 (Copy)', '*workflow1 (Copy) (2)'])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)',
'*workflow1 (Copy) (3)'
])
await expect
.poll(() => tab.getOpenedWorkflowNames())
.toEqual([
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)',
'*workflow1 (Copy) (3)'
])
})
test('Can open workflow after insert', async ({ comfyPage }) => {
@@ -111,10 +111,9 @@ test.describe('Workflows sidebar', () => {
const openedWorkflow = tab.getOpenedItem('foo/bar')
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow',
'foo/baz'
])
await expect
.poll(() => tab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow', 'foo/baz'])
})
test('Can save workflow as', async ({ comfyPage }) => {
@@ -134,20 +133,28 @@ test.describe('Workflows sidebar', () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: false
})
expect(exportedWorkflow).toBeDefined()
for (const node of exportedWorkflow.nodes) {
for (const slot of node.inputs ?? []) {
expect(slot.localized_name).toBeUndefined()
expect(slot.label).toBeUndefined()
}
for (const slot of node.outputs ?? []) {
expect(slot.localized_name).toBeUndefined()
expect(slot.label).toBeUndefined()
}
}
await expect
.poll(async () => {
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: false
})
for (const node of exportedWorkflow.nodes) {
for (const slot of node.inputs ?? []) {
if (slot.localized_name !== undefined)
return `input localized_name found: ${slot.localized_name}`
if (slot.label !== undefined)
return `input label found: ${slot.label}`
}
for (const slot of node.outputs ?? []) {
if (slot.localized_name !== undefined)
return `output localized_name found: ${slot.localized_name}`
if (slot.label !== undefined)
return `output label found: ${slot.label}`
}
}
return 'ok'
})
.toBe('ok')
})
test('Can export same workflow with different locales', async ({
@@ -164,6 +171,9 @@ test.describe('Workflows sidebar', () => {
expect(download.suggestedFilename()).toBe('exported_default.json')
// Get the exported workflow content
await expect
.poll(() => comfyPage.workflow.getExportedWorkflow({ api: false }))
.toBeDefined()
const downloadedContent = await comfyPage.workflow.getExportedWorkflow({
api: false
})
@@ -171,42 +181,49 @@ test.describe('Workflows sidebar', () => {
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
await comfyPage.setup()
const downloadedContentZh = await comfyPage.workflow.getExportedWorkflow({
api: false
})
// Compare the exported workflow with the original
delete downloadedContent.id
delete downloadedContentZh.id
expect(downloadedContent).toBeDefined()
expect(downloadedContent).toEqual(downloadedContentZh)
await expect
.poll(async () => {
const downloadedContentZh =
await comfyPage.workflow.getExportedWorkflow({ api: false })
delete downloadedContentZh.id
return downloadedContentZh
})
.toEqual(downloadedContent)
})
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow5'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow5')
await comfyPage.confirmDialog.click('overwrite')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow5'])
})
test('Can save temporary workflow with unmodified name', async ({
comfyPage
}) => {
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
await comfyPage.menu.topbar.saveWorkflow('Unsaved Workflow')
// Should not trigger the overwrite dialog
expect(
await comfyPage.page.locator('.comfy-modal-content:visible').count()
).toBe(0)
await expect
.poll(() =>
comfyPage.page.locator('.comfy-modal-content:visible').count()
)
.toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
})
test('Can overwrite other workflows with save as', async ({ comfyPage }) => {
@@ -282,7 +299,9 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1')
await comfyPage.command.executeCommand('Workspace.CloseWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
await expect
.poll(() => tab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow'])
})
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
@@ -292,17 +311,17 @@ test.describe('Workflows sidebar', () => {
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual([filename])
await workflowsTab.getOpenedItem(filename).click({ button: 'right' })
await comfyPage.nextFrame()
await comfyPage.contextMenu.clickMenuItem('Delete')
await comfyPage.nextFrame()
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow'
])
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow'])
})
test('Can delete workflows', async ({ comfyPage }) => {
@@ -310,7 +329,9 @@ test.describe('Workflows sidebar', () => {
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual([filename])
await workflowsTab.getOpenedItem(filename).click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Delete')
@@ -319,9 +340,9 @@ test.describe('Workflows sidebar', () => {
await comfyPage.confirmDialog.click('delete')
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow'
])
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow'])
})
test('Can duplicate workflow from context menu', async ({ comfyPage }) => {
@@ -372,7 +393,7 @@ test.describe('Workflows sidebar', () => {
// Wait for nodes to be inserted after drag-drop with retryable assertion
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(nodeCount * 2)
})
})

View File

@@ -88,7 +88,7 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
await expect.poll(() => subgraphNode.exists()).toBe(true)
const initialNodeCount = await comfyPage.subgraph.getNodeCount()

View File

@@ -34,7 +34,19 @@ test.describe(
}))
})
expect(positionsBefore.length).toBeGreaterThan(0)
await expect
.poll(async () => {
const positions = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
return positions.length
})
.toBeGreaterThan(0)
// Wait for the debounced draft persistence to flush to localStorage
await comfyPage.workflow.waitForDraftPersisted()
@@ -55,23 +67,25 @@ test.describe(
.toBe(true)
// Verify all internal node positions are preserved
const positionsAfter = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
for (const before of positionsBefore) {
const after = positionsAfter.find((n) => n.id === before.id)
expect(
after,
`Node ${before.id} should exist after reload`
).toBeDefined()
expect(after!.x).toBeCloseTo(before.x, 0)
expect(after!.y).toBeCloseTo(before.y, 0)
await expect
.poll(async () => {
const positionsNow = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
const after = positionsNow.find((n) => n.id === before.id)
if (!after) return null
return { x: after.x, y: after.y }
})
.toMatchObject({
x: expect.closeTo(before.x, 0),
y: expect.closeTo(before.y, 0)
})
}
})
}

View File

@@ -27,14 +27,14 @@ test.describe(
// Convert first CLIP Text Encode node to subgraph
await openVueNodeContextMenu(comfyPage, clipNodeTitle)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
await comfyPage.nextFrame()
await comfyPage.contextMenu.waitForHidden()
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()
await comfyPage.contextMenu.waitForHidden()
// Capture both subgraph node IDs
const subgraphNodes = comfyPage.vueNodes.getNodeByTitle('New Subgraph')

View File

@@ -46,10 +46,13 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await expect
.poll(async () => {
const widgets = await getPseudoPreviewWidgets(comfyPage, '5')
return widgets.length
})
.toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
@@ -71,17 +74,20 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await expect
.poll(async () => {
const widgets = await getPseudoPreviewWidgets(comfyPage, '5')
return widgets.length
})
.toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await expect.poll(() => subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect.poll(() => subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())

View File

@@ -51,7 +51,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = await breadcrumb.textContent()
const initialBreadcrumbText = (await breadcrumb.textContent()) ?? ''
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
@@ -74,9 +74,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
await expect(breadcrumb).toBeVisible()
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
await expect(breadcrumb).toContainText(UPDATED_SUBGRAPH_TITLE)
await expect(breadcrumb).not.toHaveText(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context and hides the breadcrumb', async ({
@@ -156,10 +155,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(
await comfyPage.subgraph.isInSubgraph(),
'Escape should stay inside the subgraph after the default binding is unset'
).toBe(true)
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Escape should stay inside the subgraph after the default binding is unset'
})
.toBe(true)
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
@@ -178,10 +179,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
expect(
await comfyPage.subgraph.isInSubgraph(),
'Precondition failed: expected to be inside the subgraph before opening settings'
).toBe(true)
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Precondition failed: expected to be inside the subgraph before opening settings'
})
.toBe(true)
await comfyPage.page.keyboard.press('Control+,')
await expect(
@@ -217,7 +220,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2_000
timeout: 5_000
})
.toBe(true)
})
@@ -235,7 +238,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2_000
timeout: 5_000
})
.toBe(true)
})
@@ -266,7 +269,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2_000 }
{ timeout: 5_000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
@@ -292,10 +295,14 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
node.progress = 0.5
}, subgraphNodeId)
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId)
expect(progressBefore).toBe(0.5)
await expect
.poll(() =>
comfyPage.page.evaluate(
(nodeId) => window.app!.canvas.graph!.getNodeById(nodeId)!.progress,
subgraphNodeId
)
)
.toBe(0.5)
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)

View File

@@ -46,36 +46,37 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
await comfyExpect(async () => {
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) =>
w.name.startsWith('text')
)
comfyExpect(textWidgets).toHaveLength(2)
const textWidgets = widgetValues.filter((w) =>
w.name.startsWith('text')
)
comfyExpect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
const values = textWidgets.map((w) => w.value)
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
}).toPass({ timeout: 5_000 })
})
}
)

View File

@@ -22,11 +22,19 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id ?? null
})
)
.not.toBeNull()
const nodeId = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id || null
return nodes?.[0]?.id ?? null
})
expect(nodeId).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')

View File

@@ -52,7 +52,7 @@ test.describe(
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
expect(await subgraphNode.exists()).toBe(true)
await expect.poll(() => subgraphNode.exists()).toBe(true)
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
@@ -217,7 +217,7 @@ test.describe(
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
await expect(promoteEntry).toBeHidden()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -252,13 +252,13 @@ test.describe(
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Wait for the context menu to close, confirming the action completed.
await expect(promoteEntry).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -283,7 +283,7 @@ test.describe(
await expect(unpromoteEntry).toBeVisible()
await unpromoteEntry.click()
await comfyPage.nextFrame()
await expect(unpromoteEntry).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -389,9 +389,14 @@ test.describe(
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('filename_prefix')
expect(promotedNames.some((name) => name.startsWith('$$'))).toBe(true)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(
expect.arrayContaining([
'filename_prefix',
expect.stringMatching(/^\$\$/)
])
)
const loadImageNode = await comfyPage.nodeOps.getNodeRefById('11')
const loadImagePosition = await loadImageNode.getPosition()
@@ -425,20 +430,22 @@ test.describe(
)
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
await expect
.poll(async () => {
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
return disabledState.find((w) => w.name === 'string_a')?.disabled
})
.toBe(true)
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -476,15 +483,16 @@ test.describe(
await comfyPage.nextFrame()
// Verify promotions exist
const namesBefore = await getPromotedWidgetNames(comfyPage, '11')
expect(namesBefore.length).toBeGreaterThan(0)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
.toEqual(expect.arrayContaining([expect.anything()]))
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
// Node no longer exists, so promoted widgets should be gone
expect(await subgraphNode.exists()).toBe(false)
await expect.poll(() => subgraphNode.exists()).toBe(false)
})
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
@@ -503,12 +511,20 @@ test.describe(
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.name ?? null
})
})
.not.toBeNull()
const removedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.name ?? null
})
expect(removedSlotName).not.toBeNull()
await comfyPage.subgraph.removeSlot('input')

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