Compare commits

..

9 Commits

Author SHA1 Message Date
jaeone94
920f8aba9a fix: remove unused transparent border from node body 2026-03-30 17:45:18 +09:00
jaeone94
7b39902d74 fix: add box-border to root to prevent border from enlarging node 2026-03-30 17:27:03 +09:00
jaeone94
d74cfe165c fix: update unit tests for border/outline unification
- Remove overlay div assertions (node-state-outline-overlay removed)
- Add createSharedComposable to @vueuse/core mock
- Add offsetWidth/offsetHeight to ResizeObserver test elements
2026-03-30 16:27:21 +09:00
jaeone94
5b8d21bfa1 refactor: address test code review feedback
- Rename loadWithPositions to repositionNodes, decompose into
  getSerializedGraph + applyNodePositions (pure) + loadGraph
- Extract measureSelectionBounds to shared boundsUtils helper
- Add JSDoc to setCollapsed
- Move test helpers out of spec file into fixture utils
2026-03-30 02:54:59 +09:00
jaeone94
8b97cfb28d fix: accurate bounding rect for Vue nodes with footer
- Measure body (node-inner-wrapper) for node.size to exclude footer
  height, preventing size accumulation on Vue/legacy mode switching
- Store footer height separately (_footerHeight) for boundingRect
- Store collapsed width/height from DOM in ResizeObserver instead of
  relying on canvas text measurement
- Skip _collapsed_width text measurement in Vue nodes mode since
  measure() ctx overwrites ResizeObserver values
- Restore selectionBorder.ts to use createBounds (no per-frame DOM)
2026-03-30 02:12:45 +09:00
jaeone94
e93c5569c6 style: fine-tune footer overlap and inline sizing constants 2026-03-30 00:21:21 +09:00
jaeone94
0a3d4fd5bc fix: resize handles for footer nodes
- Split SE/SW handles: body handles hide when footer exists,
  footer-level handles render after NodeFooter
- Use body element (node-inner-wrapper) for resize start size
  instead of root element to exclude footer height
- Add z-10 to resize handles so they appear above footer buttons
- Restore hasFooter computed for handle visibility control
2026-03-30 00:04:14 +09:00
jaeone94
0d0cf610f2 refactor: unify node border and selection outline on root element
- Move border from overlay div to root element directly
- Replace separate selection/execution overlay with outline on root
- Remove overlay div, selectionShapeClass computed, and hasFooter
- Inline all footer sizing constants
- Add ring-inset border for Enter/Advanced buttons in error state
- Hide root border in error state (transparent) since ring-4 covers it
- Remove isCollapsed prop from NodeFooter (no longer needed)
2026-03-29 23:41:24 +09:00
jaeone94
ea878535e2 refactor: inline node footer layout with z-index layering
- Replace absolute overlay footer with inline flow layout
- Use z-index layering: body(z-5) > footer(z-0/z-2) to keep footer
  behind body while maintaining hover interactivity
- Move resize handles inside body to avoid footer overlap
- Use border-2 with negative inset for root border overlay to render
  outside body bounds, preventing slot dot occlusion (z-0)
- Shape-aware radius classes for error/enter/footer buttons
- Remove hasFooter computed and all footer offset classes
2026-03-29 18:46:00 +09:00
216 changed files with 4903 additions and 20029 deletions

View File

@@ -1,74 +0,0 @@
---
name: playwright-e2e
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
severity-default: medium
tools: [Read, Grep]
---
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
Reference docs (read if you need full context):
- `browser_tests/README.md` — setup, patterns, screenshot workflow
- `browser_tests/AGENTS.md` — directory structure, fixture overview
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
## Checks
### Flakiness Risks (Major)
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
3. **Keyboard actions without prior focus**`page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
7. **Double-click without `{ delay }` option**`dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
### Fixture & API Misuse (Medium)
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
- `comfyPage.command.executeCommand()` for menu/command actions
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
- `comfyPage.canvasOps.resetView()` for view reset
- `comfyPage.settings.setSetting()` for settings
- Component page objects in `browser_tests/fixtures/components/`
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
### Convention Violations (Minor)
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
15. **Debug helpers left in committed code**`debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
### Test Design (Nitpick)
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
## Rules
- Only review `.spec.ts` files and supporting code in `browser_tests/`
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
- Existing tests that predate conventions are acceptable to modify but not required to fix

View File

@@ -1,84 +0,0 @@
---
name: adding-deprecation-warnings
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
---
# Adding Deprecation Warnings
Adds backward-compatible deprecation warnings for renamed or removed
properties using the `defineDeprecatedProperty` helper in
`src/lib/litegraph/src/utils/feedback.ts`.
## When to Use
- A property or API has been renamed and custom nodes still use the old name
- A property is being removed but needs a grace period
- Backward compatibility must be preserved while nudging adoption
## Steps
### 1. Search the Custom Node Ecosystem
Before implementing, assess impact by searching for usage of the
deprecated property across ComfyUI custom nodes:
```text
Use the comfy_codesearch tool to search for the old property name.
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
```
Document the usage patterns found (property access, truthiness checks,
caching to local vars, style mutation, etc.) — these all must continue
working.
### 2. Apply the Deprecation
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
```typescript
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
/** @deprecated Use {@link obj.newProp} instead. */
defineDeprecatedProperty(
obj,
'oldProp',
'newProp',
'obj.oldProp is deprecated. Use obj.newProp instead.'
)
```
### 3. Checklist
- [ ] Ecosystem search completed — all usage patterns are compatible
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
- [ ] Warning message names both old and new property clearly
- [ ] `pnpm typecheck` passes
- [ ] `pnpm lint` passes
### 4. PR Comment
Add a PR comment summarizing the ecosystem search results: which repos
use the deprecated property, what access patterns were found, and
confirmation that all patterns are compatible with the ODP getter/setter.
## How `defineDeprecatedProperty` Works
- Creates an `Object.defineProperty` getter/setter on the target object
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
- Both log via `warnDeprecated`, which deduplicates (once per unique
message per session via a `Set`)
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
/ `JSON.stringify`
- `configurable: true` allows further redefinition if needed
## Edge Cases
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
once then the cached ref is used directly
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
works, getter returns the real object
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
once per session regardless of call count

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -74,6 +76,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -95,7 +99,7 @@ jobs:
if npx license-checker-rseidelsohn@4 \
--production \
--summary \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--clarificationsFile .github/license-clarifications.json \
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
echo ''

View File

@@ -33,20 +33,6 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6
with:
name: frontend-dist-cloud
path: dist/
retention-days: 1
# Sharded chromium tests
playwright-tests-chromium-sharded:
needs: setup
@@ -111,14 +97,14 @@ jobs:
strategy:
fail-fast: false
matrix:
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v7
with:
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
name: frontend-dist
path: dist/
- name: Start ComfyUI server

View File

@@ -1,4 +1,4 @@
# Description: Unit and component testing with Vitest + coverage reporting
# Description: Unit and component testing with Vitest
name: 'CI: Tests Unit'
on:
@@ -23,12 +23,5 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
fail_ci_if_error: false
- name: Run Vitest tests
run: pnpm test:unit

View File

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

View File

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

View File

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

View File

@@ -20,10 +20,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

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

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

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

View File

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

View File

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

View File

@@ -216,7 +216,6 @@ See @docs/testing/\*.md for detailed patterns.
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
## External Resources

View File

@@ -41,46 +41,12 @@
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
# Painter
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028ZM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38Zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z"/></svg>

Before

Width:  |  Height:  |  Size: 819 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z"/></svg>

Before

Width:  |  Height:  |  Size: 988 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>

Before

Width:  |  Height:  |  Size: 536 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm5.8 11.33c.02.16.03.33.03.5 0 2.55-2.97 4.63-6.63 4.63-3.65 0-6.62-2.07-6.62-4.63 0-.17.01-.34.03-.5a1.58 1.58 0 0 1-.63-1.27c0-.88.72-1.59 1.6-1.59.44 0 .83.18 1.12.46 1.1-.79 2.62-1.3 4.31-1.37l.73-3.44a.32.32 0 0 1 .39-.24l2.43.52a1.13 1.13 0 0 1 2.15.36 1.13 1.13 0 0 1-1.13 1.12 1.13 1.13 0 0 1-1.08-.82l-2.16-.46-.65 3.07c1.65.09 3.14.59 4.22 1.36.29-.28.69-.46 1.13-.46.88 0 1.6.71 1.6 1.59 0 .52-.25.97-.63 1.27ZM9.5 13.5c0 .63.51 1.13 1.13 1.13s1.12-.5 1.12-1.13-.5-1.12-1.12-1.12-1.13.5-1.13 1.12Zm5.75 2.55c-.69.69-2 .73-3.25.73s-2.56-.04-3.25-.73a.32.32 0 1 1 .45-.45c.44.44 1.37.6 2.8.6 1.43 0 2.37-.16 2.8-.6a.32.32 0 1 1 .45.45Zm-.37-1.42c.62 0 1.13-.5 1.13-1.13 0-.62-.51-1.12-1.13-1.12-.63 0-1.13.5-1.13 1.12 0 .63.5 1.13 1.13 1.13Z"/></svg>

Before

Width:  |  Height:  |  Size: 915 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>

Before

Width:  |  Height:  |  Size: 254 B

View File

@@ -1,143 +0,0 @@
<script setup lang="ts">
const columns = [
{
title: 'Product',
links: [
{ label: 'Comfy Desktop', href: '/download' },
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
{ label: 'Pricing', href: '/pricing' }
]
},
{
title: 'Resources',
links: [
{ label: 'Documentation', href: 'https://docs.comfy.org' },
{ label: 'Blog', href: 'https://blog.comfy.org' },
{ label: 'Gallery', href: '/gallery' },
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
]
},
{
title: 'Company',
links: [
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
]
},
{
title: 'Legal',
links: [
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
]
}
]
const socials = [
{
label: 'GitHub',
href: 'https://github.com/comfyanonymous/ComfyUI',
icon: '/icons/social/github.svg'
},
{
label: 'Discord',
href: 'https://discord.gg/comfyorg',
icon: '/icons/social/discord.svg'
},
{
label: 'X',
href: 'https://x.com/comaboratory',
icon: '/icons/social/x.svg'
},
{
label: 'Reddit',
href: 'https://reddit.com/r/comfyui',
icon: '/icons/social/reddit.svg'
},
{
label: 'LinkedIn',
href: 'https://linkedin.com/company/comfyorg',
icon: '/icons/social/linkedin.svg'
},
{
label: 'Instagram',
href: 'https://instagram.com/comfyorg',
icon: '/icons/social/instagram.svg'
}
]
</script>
<template>
<footer class="border-t border-white/10 bg-black">
<div
class="mx-auto grid max-w-7xl gap-8 px-6 py-16 sm:grid-cols-2 lg:grid-cols-5"
>
<!-- Brand -->
<div class="lg:col-span-1">
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
Comfy
</a>
<p class="mt-4 text-sm text-smoke-700">
Professional control of visual AI.
</p>
</div>
<!-- Link columns -->
<nav
v-for="column in columns"
:key="column.title"
:aria-label="column.title"
class="flex flex-col gap-3"
>
<h3 class="text-sm font-semibold text-white">{{ column.title }}</h3>
<a
v-for="link in column.links"
:key="link.href"
:href="link.href"
class="text-sm text-smoke-700 transition-colors hover:text-white"
>
{{ link.label }}
</a>
</nav>
</div>
<!-- Bottom bar -->
<div class="border-t border-white/10">
<div
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
>
<p class="text-sm text-smoke-700">
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
</p>
<!-- Social icons -->
<div class="flex items-center gap-4">
<a
v-for="social in socials"
:key="social.label"
:href="social.href"
:aria-label="social.label"
target="_blank"
rel="noopener noreferrer"
class="text-smoke-700 transition-colors hover:text-white"
>
<span
class="inline-block size-5 bg-current"
:style="{
maskImage: `url(${social.icon})`,
maskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskImage: `url(${social.icon})`,
WebkitMaskSize: 'contain',
WebkitMaskRepeat: 'no-repeat'
}"
aria-hidden="true"
/>
</a>
</div>
</div>
</div>
</footer>
</template>

View File

@@ -1,149 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const mobileMenuOpen = ref(false)
const currentPath = ref('')
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
const ctaLinks = [
{
label: 'COMFY CLOUD',
href: 'https://app.comfy.org',
primary: true
},
{
label: 'COMFY HUB',
href: 'https://hub.comfy.org',
primary: false
}
]
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && mobileMenuOpen.value) {
mobileMenuOpen.value = false
}
}
function onAfterSwap() {
mobileMenuOpen.value = false
currentPath.value = window.location.pathname
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
document.addEventListener('astro:after-swap', onAfterSwap)
currentPath.value = window.location.pathname
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
document.removeEventListener('astro:after-swap', onAfterSwap)
})
</script>
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
Comfy
</a>
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
>
{{ link.label }}
</a>
<div class="flex items-center gap-3">
<a
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
"
class="rounded-full px-5 py-2 text-sm font-semibold"
>
{{ cta.label }}
</a>
</div>
</div>
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
aria-label="Toggle menu"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && 'translate-y-2 rotate-45'"
/>
<span
class="block h-0.5 w-6 bg-white transition-opacity"
:class="mobileMenuOpen && 'opacity-0'"
/>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && '-translate-y-2 -rotate-45'"
/>
</button>
</div>
<!-- Mobile menu -->
<div
v-show="mobileMenuOpen"
id="site-mobile-menu"
class="border-t border-white/10 bg-black px-6 pb-6 md:hidden"
>
<div class="flex flex-col gap-4 pt-4">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
@click="mobileMenuOpen = false"
>
{{ link.label }}
</a>
<div class="flex flex-col gap-3 pt-2">
<a
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
"
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
>
{{ cta.label }}
</a>
</div>
</div>
</div>
</nav>
</template>

View File

@@ -1,86 +0,0 @@
---
import { ClientRouter } from 'astro:transitions'
import Analytics from '@vercel/analytics/astro'
import '../styles/global.css'
interface Props {
title: string
description?: string
ogImage?: string
}
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
ogImage = '/og-default.png',
} = Astro.props
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const locale = Astro.currentLocale ?? 'en'
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImageURL.href} />
<meta property="og:url" content={canonicalURL.href} />
<meta property="og:locale" content={locale} />
<meta property="og:site_name" content="Comfy" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Google Tag Manager -->
{gtmEnabled && (
<script is:inline define:vars={{ gtmId }}>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' })
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', gtmId)
</script>
)}
<ClientRouter />
</head>
<body class="bg-black text-white font-inter antialiased">
{gtmEnabled && (
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
height="0"
width="0"
style="display:none;visibility:hidden"
></iframe>
</noscript>
)}
<slot />
<Analytics />
</body>
</html>

View File

@@ -5,5 +5,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.ts"]
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -12,13 +12,12 @@ browser_tests/
│ ├── ComfyMouse.ts - Mouse interaction helper
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
│ ├── components/ - Page object components (locators, user interactions)
│ ├── components/ - Page object components
│ │ ├── ContextMenu.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ └── Topbar.ts
│ ├── helpers/ - Focused helper classes (domain-specific actions)
│ ├── helpers/ - Focused helper classes
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
│ │ ├── KeyboardHelper.ts
@@ -26,36 +25,11 @@ browser_tests/
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Pure utility functions (no page dependency)
│ └── utils/ - Utility functions
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
## Polling Assertions
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
```typescript
// ✅ Correct — single async call + single assertion
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
.toBe(0)
// ❌ Avoid — nested expect inside toPass
await expect(async () => {
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 250 })
```
Reserve `toPass()` for blocks with multiple assertions or complex async logic that can't be expressed as a single polled value.
## Gotchas
| Symptom | Cause | Fix |

View File

@@ -1,30 +0,0 @@
{
"1": {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 20,
"cfg": 8.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0
},
"_meta": { "title": "KSampler" }
},
"2": {
"class_type": "NonExistentCustomNode_XYZ_12345",
"inputs": {
"input1": "test"
},
"_meta": { "title": "Missing Node" }
},
"3": {
"class_type": "EmptyLatentImage",
"inputs": {
"width": 512,
"height": 512,
"batch_size": 1
},
"_meta": { "title": "Empty Latent Image" }
}
}

View File

@@ -0,0 +1,116 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,10 +1,13 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { test as base } from '@playwright/test'
import type {
APIRequestContext,
ExpectMatcherState,
Locator,
Page
} from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import { config as dotenvConfig } from 'dotenv'
import { TestIds } from './selectors'
import { sleep } from './utils/timing'
import { comfyExpect } from './utils/customMatchers'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyTemplates } from '../helpers/templates'
@@ -15,7 +18,6 @@ import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import { QueuePanel } from './components/QueuePanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
@@ -37,7 +39,7 @@ import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
import { assetPath } from './utils/paths'
import type { NodeReference } from './utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
@@ -122,7 +124,7 @@ type KeysOfType<T, Match> = {
}[keyof T]
class ConfirmDialog {
public readonly root: Locator
private readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
@@ -197,7 +199,6 @@ export class ComfyPage {
public readonly featureFlags: FeatureFlagHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
@@ -241,11 +242,10 @@ export class ComfyPage {
this.workflow = new WorkflowHelper(this)
this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page)
this.dragDrop = new DragDropHelper(page)
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
@@ -343,9 +343,8 @@ export class ComfyPage {
await this.nextFrame()
}
/** @deprecated Use standalone `assetPath` from `browser_tests/fixtures/utils/assetPath` directly. */
public assetPath(fileName: string) {
return assetPath(fileName)
return `./browser_tests/assets/${fileName}`
}
async goto() {
@@ -359,7 +358,7 @@ export class ComfyPage {
}
async delay(ms: number) {
return sleep(ms)
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
@@ -477,4 +476,49 @@ export const comfyPageFixture = base.extend<{
}
})
export { comfyExpect }
const makeMatcher = function <T>(
getValue: (node: NodeReference) => Promise<T> | T,
type: string
) {
return async function (
this: ExpectMatcherState,
node: NodeReference,
options?: { timeout?: number; intervals?: number[] }
) {
const value = await getValue(node)
let assertion = expect(
value,
'Node is ' + (this.isNot ? '' : 'not ') + type
)
if (this.isNot) {
assertion = assertion.not
}
await expect(async () => {
assertion.toBeTruthy()
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
}
}
}
export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
const isFocused = await locator.evaluate(
(el) => el === document.activeElement
)
await expect(async () => {
expect(isFocused).toBe(!this.isNot)
}).toPass(options)
return {
pass: isFocused,
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})

View File

@@ -31,14 +31,10 @@ export class VueNodeHelpers {
}
/**
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
* Get locator for a Vue node by the node's title (displayed name in the header)
*/
getNodeByTitle(title: string): Locator {
return this.page.locator('[data-node-id]').filter({
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
})
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
}
/**

View File

@@ -1,4 +1,3 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
@@ -34,20 +33,6 @@ export class ContextMenu {
return primeVueVisible || litegraphVisible
}
async assertHasItems(items: string[]): Promise<void> {
for (const item of items) {
await expect
.soft(this.page.getByRole('menuitem', { name: item }))
.toBeVisible()
}
}
async openFor(locator: Locator): Promise<this> {
await locator.click({ button: 'right' })
await expect.poll(() => this.isVisible()).toBe(true)
return this
}
async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()

View File

@@ -1,24 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { comfyExpect as expect } from '../ComfyPage'
import { TestIds } from '../selectors'
export class QueuePanel {
readonly overlayToggle: Locator
readonly moreOptionsButton: Locator
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
}
async openClearHistoryDialog() {
await this.moreOptionsButton.click()
const clearHistoryAction = this.page.getByTestId(
TestIds.queue.clearHistoryAction
)
await expect(clearHistoryAction).toBeVisible()
await clearHistoryAction.click()
}
}

View File

@@ -1,77 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { BaseDialog } from './BaseDialog'
export class SignInDialog extends BaseDialog {
readonly emailInput: Locator
readonly passwordInput: Locator
readonly signInButton: Locator
readonly forgotPasswordLink: Locator
readonly apiKeyButton: Locator
readonly termsLink: Locator
readonly privacyLink: Locator
constructor(page: Page) {
super(page)
this.emailInput = this.root.locator('#comfy-org-sign-in-email')
this.passwordInput = this.root.locator('#comfy-org-sign-in-password')
this.signInButton = this.root.getByRole('button', { name: 'Sign in' })
this.forgotPasswordLink = this.root.getByText('Forgot password?')
this.apiKeyButton = this.root.getByRole('button', {
name: 'Comfy API Key'
})
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
}
async open() {
await this.page.evaluate(() => {
void window.app!.extensionManager.dialog.showSignInDialog()
})
await this.waitForVisible()
}
get heading() {
return this.root.getByRole('heading').first()
}
get signUpLink() {
return this.root.getByText('Sign up', { exact: true })
}
get signInLink() {
return this.root.getByText('Sign in', { exact: true })
}
get signUpEmailInput() {
return this.root.locator('#comfy-org-sign-up-email')
}
get signUpPasswordInput() {
return this.root.locator('#comfy-org-sign-up-password')
}
get signUpConfirmPasswordInput() {
return this.root.locator('#comfy-org-sign-up-confirm-password')
}
get signUpButton() {
return this.root.getByRole('button', { name: 'Sign up', exact: true })
}
get apiKeyHeading() {
return this.root.getByRole('heading', { name: 'API Key' })
}
get apiKeyInput() {
return this.root.locator('#comfy-org-api-key')
}
get backButton() {
return this.root.getByRole('button', { name: 'Back' })
}
get dividerText() {
return this.root.getByText('Or continue with')
}
}

View File

@@ -1,41 +0,0 @@
# Mock Data Fixtures
Deterministic mock data for browser (Playwright) tests. Each fixture
exports typed objects that conform to generated types from
`packages/ingest-types` or Zod schemas in `src/schemas/`.
## Usage with `page.route()`
> **Note:** `comfyPageFixture` navigates to the app during `setup()`,
> before the test body runs. Routes must be registered before navigation
> to intercept initial page-load requests. Set up routes in a custom
> fixture or `test.beforeEach` that runs before `comfyPage.setup()`.
```ts
import { createMockNodeDefinitions } from '../fixtures/data/nodeDefinitions'
import { mockSystemStats } from '../fixtures/data/systemStats'
// Extend the base set with test-specific nodes
const nodeDefs = createMockNodeDefinitions({
MyCustomNode: {
/* ... */
}
})
await page.route('**/api/object_info', (route) =>
route.fulfill({ json: nodeDefs })
)
await page.route('**/api/system_stats', (route) =>
route.fulfill({ json: mockSystemStats })
)
```
## Adding new fixtures
1. Locate the generated type in `packages/ingest-types` or Zod schema
in `src/schemas/` for the endpoint you need.
2. Create a new `.ts` file here that imports and satisfies the
corresponding TypeScript type.
3. Keep values realistic but stable — avoid dates, random IDs, or
values that would cause test flakiness.

View File

@@ -1,155 +0,0 @@
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
/**
* Base node definitions covering the default workflow.
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
*/
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
KSampler: {
input: {
required: {
model: ['MODEL', {}],
seed: [
'INT',
{
default: 0,
min: 0,
max: 0xfffffffffffff,
control_after_generate: true
}
],
steps: ['INT', { default: 20, min: 1, max: 10000 }],
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
positive: ['CONDITIONING', {}],
negative: ['CONDITIONING', {}],
latent_image: ['LATENT', {}]
},
optional: {
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'KSampler',
display_name: 'KSampler',
description: 'Samples latents using the provided model and conditioning.',
category: 'sampling',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
{}
]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: 'Loads a diffusion model checkpoint.',
category: 'loaders',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CLIPTextEncode: {
input: {
required: {
text: ['STRING', { multiline: true, dynamicPrompts: true }],
clip: ['CLIP', {}]
}
},
output: ['CONDITIONING'],
output_is_list: [false],
output_name: ['CONDITIONING'],
name: 'CLIPTextEncode',
display_name: 'CLIP Text Encode (Prompt)',
description: 'Encodes a text prompt using a CLIP model.',
category: 'conditioning',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
EmptyLatentImage: {
input: {
required: {
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'EmptyLatentImage',
display_name: 'Empty Latent Image',
description: 'Creates an empty latent image of the specified dimensions.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
VAEDecode: {
input: {
required: {
samples: ['LATENT', {}],
vae: ['VAE', {}]
}
},
output: ['IMAGE'],
output_is_list: [false],
output_name: ['IMAGE'],
name: 'VAEDecode',
display_name: 'VAE Decode',
description: 'Decodes latent images back into pixel space.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
SaveImage: {
input: {
required: {
images: ['IMAGE', {}],
filename_prefix: ['STRING', { default: 'ComfyUI' }]
}
},
output: [],
output_is_list: [],
output_name: [],
name: 'SaveImage',
display_name: 'Save Image',
description: 'Saves images to the output directory.',
category: 'image',
output_node: true,
python_module: 'nodes',
deprecated: false,
experimental: false
}
}
export function createMockNodeDefinitions(
overrides?: Record<string, ComfyNodeDef>
): Record<string, ComfyNodeDef> {
const base = structuredClone(baseNodeDefinitions)
return overrides ? { ...base, ...overrides } : base
}

View File

@@ -1,22 +0,0 @@
import type { SystemStatsResponse } from '@comfyorg/ingest-types'
export const mockSystemStats: SystemStatsResponse = {
system: {
os: 'posix',
python_version: '3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0]',
embedded_python: false,
comfyui_version: '0.3.10',
pytorch_version: '2.4.0+cu124',
argv: ['main.py', '--listen', '0.0.0.0'],
ram_total: 67108864000,
ram_free: 52428800000
},
devices: [
{
name: 'NVIDIA GeForce RTX 4090',
type: 'cuda',
vram_total: 25769803776,
vram_free: 23622320128
}
]
}

View File

@@ -3,28 +3,17 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import { BuilderFooterHelper } from './BuilderFooterHelper'
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
import { BuilderSelectHelper } from './BuilderSelectHelper'
import { BuilderStepsHelper } from './BuilderStepsHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
}
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
@@ -35,6 +24,42 @@ export class AppModeHelper {
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
@@ -93,4 +118,107 @@ export class AppModeHelper {
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** The builder footer nav containing save/navigation buttons. */
private get builderFooterNav(): Locator {
return this.page
.getByRole('button', { name: 'Exit app builder' })
.locator('..')
}
/** Get a button in the builder footer by its accessible name. */
getFooterButton(name: string | RegExp): Locator {
return this.builderFooterNav.getByRole('button', { name })
}
/** Click the save/save-as button in the builder footer. */
async clickSave() {
await this.getFooterButton(/^Save/).first().click()
await this.comfyPage.nextFrame()
}
/** The "Opens as" popover tab above the builder footer. */
get opensAsPopover(): Locator {
return this.page.getByTestId(TestIds.builder.opensAs)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInputViaMenu(title: string, newName: string) {
const menu = this.getBuilderInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title to trigger
* inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.filter({ hasText: title })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
}

View File

@@ -1,69 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class BuilderFooterHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
get nav(): Locator {
return this.page.getByTestId(TestIds.builder.footerNav)
}
get exitButton(): Locator {
return this.buttonByName('Exit app builder')
}
get nextButton(): Locator {
return this.buttonByName('Next')
}
get backButton(): Locator {
return this.buttonByName('Back')
}
get saveButton(): Locator {
return this.page.getByTestId(TestIds.builder.saveButton)
}
get saveAsButton(): Locator {
return this.page.getByTestId(TestIds.builder.saveAsButton)
}
get saveAsChevron(): Locator {
return this.page.getByTestId(TestIds.builder.saveAsChevron)
}
get opensAsPopover(): Locator {
return this.page.getByTestId(TestIds.builder.opensAs)
}
private buttonByName(name: string): Locator {
return this.nav.getByRole('button', { name })
}
async next() {
await this.nextButton.click()
await this.comfyPage.nextFrame()
}
async back() {
await this.backButton.click()
await this.comfyPage.nextFrame()
}
async exitBuilder() {
await this.exitButton.click()
await this.comfyPage.nextFrame()
}
async openSaveAsFromChevron() {
await this.saveAsChevron.click()
await this.page.getByRole('menuitem', { name: 'Save as' }).click()
await this.comfyPage.nextFrame()
}
}

View File

@@ -1,78 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
export class BuilderSaveAsHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/** The save-as dialog (scoped by aria-labelledby). */
get dialog(): Locator {
return this.page.locator('[aria-labelledby="builder-save"]')
}
/** The post-save success dialog (scoped by aria-labelledby). */
get successDialog(): Locator {
return this.page.locator('[aria-labelledby="builder-save-success"]')
}
get title(): Locator {
return this.dialog.getByText('Save as')
}
get radioGroup(): Locator {
return this.dialog.getByRole('radiogroup')
}
get nameInput(): Locator {
return this.dialog.getByRole('textbox')
}
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
return this.dialog.getByRole('radio', { name: viewType })
}
get saveButton(): Locator {
return this.dialog.getByRole('button', { name: 'Save' })
}
get successMessage(): Locator {
return this.successDialog.getByText('Successfully saved')
}
get viewAppButton(): Locator {
return this.successDialog.getByRole('button', { name: 'View app' })
}
get closeButton(): Locator {
return this.successDialog
.getByRole('button', { name: 'Close', exact: true })
.filter({ hasText: 'Close' })
}
/** The X button to dismiss the success dialog without any action. */
get dismissButton(): Locator {
return this.successDialog.locator('button.p-dialog-close-button')
}
get exitBuilderButton(): Locator {
return this.successDialog.getByRole('button', { name: 'Exit builder' })
}
get overwriteDialog(): Locator {
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
}
get overwriteButton(): Locator {
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
}
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
await this.nameInput.fill(workflowName)
await this.viewTypeRadio(viewType).click()
await this.saveButton.click()
}
}

View File

@@ -1,139 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import type { NodeReference } from '../utils/litegraphUtils'
import { TestIds } from '../selectors'
export class BuilderSelectHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/**
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
* @param title The widget title shown in the IoItem.
*/
getInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({
has: this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
})
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the preview/arrange sidebar.
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.getByLabel(ariaLabel, { exact: true })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** Delete a builder input via its actions menu. */
async deleteInput(title: string) {
const menu = this.getInputItemMenu(title)
await menu.click()
await this.page.getByText('Delete', { exact: true }).click()
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameInputViaMenu(title: string, newName: string) {
const menu = this.getInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title for inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a widget via its actions popover (works in preview and app mode).
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async selectInputWidget(node: NodeReference) {
await this.comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await this.page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await this.comfyPage.nextFrame()
}
/** Click the first SaveImage/PreviewImage node on the canvas. */
async selectOutputNode() {
const saveImageNodeId = await this.page.evaluate(() => {
const node = window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
return node ? String(node.id) : null
})
if (!saveImageNodeId)
throw new Error('SaveImage/PreviewImage node not found')
const saveImageRef =
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await this.page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await this.comfyPage.nextFrame()
}
}

View File

@@ -1,30 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
export class BuilderStepsHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
get toolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
async goToInputs() {
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
async goToOutputs() {
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
async goToPreview() {
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
}

View File

@@ -169,39 +169,6 @@ export class CanvasHelper {
})
}
/**
* Pan the canvas back and forth in a sweep pattern using middle-mouse drag.
* Each step advances one animation frame, giving per-frame measurement
* granularity for performance tests.
*/
async panSweep(options?: {
steps?: number
dx?: number
dy?: number
}): Promise<void> {
const { steps = 120, dx = 8, dy = 3 } = options ?? {}
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await this.page.mouse.move(centerX, centerY)
await this.page.mouse.down({ button: 'middle' })
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,

View File

@@ -4,10 +4,12 @@ import type { Page } from '@playwright/test'
import type { Position } from '../types'
import { getMimeType } from './mimeTypeUtil'
import { assetPath } from '../utils/paths'
export class DragDropHelper {
constructor(private readonly page: Page) {}
constructor(
private readonly page: Page,
private readonly assetPath: (fileName: string) => string
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
@@ -47,7 +49,7 @@ export class DragDropHelper {
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = assetPath(fileName)
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
evaluateParams.fileName = fileName

View File

@@ -4,7 +4,10 @@ import type {
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
NodeId
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position, Size } from '../types'
@@ -111,6 +114,27 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -185,3 +209,13 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -30,8 +30,6 @@ export interface PerfMeasurement {
eventListeners: number
totalBlockingTimeMs: number
frameDurationMs: number
p95FrameDurationMs: number
allFrameDurationsMs: number[]
}
export class PerformanceHelper {
@@ -103,13 +101,13 @@ export class PerformanceHelper {
}
/**
* Measure individual frame durations via rAF timing over a sample window.
* Returns all per-frame durations so callers can compute avg, p95, etc.
* Measure average frame duration via rAF timing over a sample window.
* Returns average ms per frame (lower = better, 16.67 = 60fps).
*/
private async measureFrameDurations(sampleFrames = 30): Promise<number[]> {
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
return this.page.evaluate((frames) => {
return new Promise<number[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 5000)
return new Promise<number>((resolve) => {
const timeout = setTimeout(() => resolve(0), 5000)
const timestamps: number[] = []
let count = 0
function tick(ts: number) {
@@ -120,14 +118,11 @@ export class PerformanceHelper {
} else {
clearTimeout(timeout)
if (timestamps.length < 2) {
resolve([])
resolve(0)
return
}
const durations: number[] = []
for (let i = 1; i < timestamps.length; i++) {
durations.push(timestamps[i] - timestamps[i - 1])
}
resolve(durations)
const total = timestamps[timestamps.length - 1] - timestamps[0]
resolve(total / (timestamps.length - 1))
}
}
requestAnimationFrame(tick)
@@ -182,21 +177,11 @@ export class PerformanceHelper {
return after[key] - before[key]
}
const [totalBlockingTimeMs, allFrameDurationsMs] = await Promise.all([
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
this.collectTBT(),
this.measureFrameDurations()
this.measureFrameDuration()
])
const frameDurationMs =
allFrameDurationsMs.length > 0
? allFrameDurationsMs.reduce((a, b) => a + b, 0) /
allFrameDurationsMs.length
: 0
const sorted = [...allFrameDurationsMs].sort((a, b) => a - b)
const p95FrameDurationMs =
sorted.length > 0 ? sorted[Math.ceil(sorted.length * 0.95) - 1] : 0
return {
name,
durationMs: delta('Timestamp') * 1000,
@@ -212,9 +197,7 @@ export class PerformanceHelper {
scriptDurationMs: delta('ScriptDuration') * 1000,
eventListeners: delta('JSEventListeners'),
totalBlockingTimeMs,
frameDurationMs,
p95FrameDurationMs,
allFrameDurationsMs
frameDurationMs
}
}
}

View File

@@ -1,11 +1,10 @@
import { expect } from '@playwright/test'
import type { ConsoleMessage, Locator, Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
CanvasPointerEvent,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
@@ -414,138 +413,4 @@ export class SubgraphHelper {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
async getSlotCount(type: 'input' | 'output'): Promise<number> {
return this.page.evaluate((slotType: 'input' | 'output') => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return 0
return graph[`${slotType}s`]?.length ?? 0
}, type)
}
async getSlotLabel(
type: 'input' | 'output',
index = 0
): Promise<string | null> {
return this.page.evaluate(
([slotType, idx]) => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const slot = graph[`${slotType}s`]?.[idx]
return slot?.label ?? slot?.name ?? null
},
[type, index] as const
)
}
async removeSlot(type: 'input' | 'output', slotName?: string): Promise<void> {
if (type === 'input') {
await this.rightClickInputSlot(slotName)
} else {
await this.rightClickOutputSlot(slotName)
}
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await this.comfyPage.nextFrame()
}
async findSubgraphNodeId(): Promise<string> {
const id = await this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const node = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return node ? String(node.id) : null
})
if (!id) throw new Error('No subgraph node found in current graph')
return id
}
async serializeAndReload(): Promise<void> {
const serialized = await this.page.evaluate(() =>
window.app!.graph!.serialize()
)
await this.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized as ComfyWorkflowJSON
)
await this.comfyPage.nextFrame()
}
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
await this.comfyPage.workflow.loadWorkflow('default')
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await this.comfyPage.nextFrame()
return subgraphNode
}
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click()
await this.comfyPage.canvas.press('Control+a')
await this.comfyPage.nextFrame()
await this.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await this.comfyPage.nextFrame()
await this.exitViaBreadcrumb()
await this.comfyPage.canvas.click()
await this.comfyPage.nextFrame()
}
static getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}
static async expectWidgetBelowHeader(
nodeLocator: Locator,
widgetLocator: Locator
): Promise<void> {
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await widgetLocator.boundingBox()
if (!headerBox || !widgetBox)
throw new Error('Header or widget bounding box not found')
expect(widgetBox.y).toBeGreaterThan(headerBox.y + headerBox.height)
}
static collectConsoleWarnings(
page: Page,
patterns: string[] = [
'No link found',
'Failed to resolve legacy -1',
'No inner link found'
]
): { warnings: string[]; dispose: () => void } {
const warnings: string[] = []
const handler = (msg: ConsoleMessage) => {
const text = msg.text()
if (patterns.some((p) => text.includes(p))) {
warnings.push(text)
}
}
page.on('console', handler)
return { warnings, dispose: () => page.off('console', handler) }
}
}

View File

@@ -1,13 +1,11 @@
import { readFileSync } from 'fs'
import type { AppMode } from '../../../src/composables/useAppMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { WorkspaceStore } from '../../types/globals'
import type { ComfyPage } from '../ComfyPage'
import { assetPath } from '../utils/paths'
type FolderStructure = {
[key: string]: FolderStructure | string
@@ -21,7 +19,7 @@ export class WorkflowHelper {
for (const [key, value] of Object.entries(structure)) {
if (typeof value === 'string') {
const filePath = assetPath(value)
const filePath = this.comfyPage.assetPath(value)
result[key] = readFileSync(filePath, 'utf-8')
} else {
result[key] = this.convertLeafToContent(value)
@@ -60,7 +58,7 @@ export class WorkflowHelper {
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)
this.comfyPage.assetPath(`${workflowName}.json`)
)
await this.comfyPage.nextFrame()
}
@@ -106,40 +104,6 @@ export class WorkflowHelper {
})
}
async getActiveWorkflowPath(): Promise<string | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.path
})
}
async getActiveWorkflowActiveAppMode(): Promise<AppMode | null | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.activeMode
})
}
async getActiveWorkflowInitialMode(): Promise<AppMode | null | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.initialMode
})
}
async getLinearModeFromGraph(): Promise<boolean | undefined> {
return this.comfyPage.page.evaluate(() => {
return window.app!.rootGraph.extra?.linearMode as boolean | undefined
})
}
async getOpenWorkflowCount(): Promise<number> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow.workflows
.length
})
}
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
@@ -147,16 +111,6 @@ export class WorkflowHelper {
})
}
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
await this.comfyPage.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
undefined,
{ timeout }
)
}
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
async getExportedWorkflow(options?: {
api?: false

View File

@@ -20,7 +20,6 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
closeMinimapButton: 'close-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
@@ -52,8 +51,7 @@ export const TestIds = {
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button'
saveButton: 'save-workflow-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -79,10 +77,6 @@ export const TestIds = {
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
saveAsButton: 'builder-save-as-button',
saveAsChevron: 'builder-save-as-chevron',
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu',
@@ -98,10 +92,6 @@ export const TestIds = {
user: {
currentUserIndicator: 'current-user-indicator'
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
@@ -130,5 +120,4 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -0,0 +1,83 @@
import type { Page } from '@playwright/test'
export interface CanvasRect {
x: number
y: number
w: number
h: number
}
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
// Must match the padding value passed to createBounds() in selectionBorder.ts
const SELECTION_PADDING = 10
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
if (!nodeEl) continue
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -1,49 +0,0 @@
import type { ExpectMatcherState, Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { NodeReference } from './litegraphUtils'
function makeMatcher<T>(
getValue: (node: NodeReference) => Promise<T> | T,
type: string
) {
return async function (
this: ExpectMatcherState,
node: NodeReference,
options?: { timeout?: number; intervals?: number[] }
) {
await expect(async () => {
const value = await getValue(node)
const assertion = this.isNot
? expect(value, 'Node is ' + type).not
: expect(value, 'Node is not ' + type)
assertion.toBeTruthy()
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
}
}
}
export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
await expect
.poll(
() => locator.evaluate((el) => el === document.activeElement),
options
)
.toBe(!this.isNot)
const isFocused = await locator.evaluate(
(el) => el === document.activeElement
)
return {
pass: isFocused,
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})

View File

@@ -332,6 +332,17 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/** Deterministic setter using node.collapse() API (not a toggle). */
async setCollapsed(collapsed: boolean) {
await this.comfyPage.page.evaluate(
([id, collapsed]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error('Node not found')
if (node.collapsed !== collapsed) node.collapse(true)
},
[this.id, collapsed] as const
)
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}
@@ -392,11 +403,6 @@ export class NodeReference {
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async delete(): Promise<void> {
await this.click('title')
await this.comfyPage.page.keyboard.press('Delete')
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,

View File

@@ -1,3 +0,0 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}

View File

@@ -1,3 +0,0 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -6,6 +6,46 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
/** Click the first SaveImage/PreviewImage node on the canvas. */
async function selectOutputNode(comfyPage: ComfyPage) {
const { page } = comfyPage
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
const { page } = comfyPage
await comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await comfyPage.nextFrame()
}
/**
* Enter builder on the default workflow and select I/O.
*
@@ -30,11 +70,11 @@ export async function setupBuilder(
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
await appMode.select.selectInputWidget(inputNode)
await appMode.goToInputs()
await selectInputWidget(comfyPage, inputNode)
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
await appMode.goToOutputs()
await selectOutputNode(comfyPage)
return inputNode
}

View File

@@ -12,43 +12,10 @@ export interface PerfReport {
const TEMP_DIR = join('test-results', 'perf-temp')
type MeasurementField = keyof PerfMeasurement
const FIELD_FORMATTERS: Record<string, (m: PerfMeasurement) => string> = {
styleRecalcs: (m) => `${m.styleRecalcs} recalcs`,
layouts: (m) => `${m.layouts} layouts`,
taskDurationMs: (m) => `${m.taskDurationMs.toFixed(1)}ms task`,
layoutDurationMs: (m) => `${m.layoutDurationMs.toFixed(1)}ms layout`,
frameDurationMs: (m) => `${m.frameDurationMs.toFixed(1)}ms/frame`,
totalBlockingTimeMs: (m) => `TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`,
durationMs: (m) => `${m.durationMs.toFixed(0)}ms total`,
heapDeltaBytes: (m) => `heap Δ${(m.heapDeltaBytes / 1024).toFixed(0)}KB`,
domNodes: (m) => `DOM Δ${m.domNodes}`,
heapUsedBytes: (m) => `heap ${(m.heapUsedBytes / 1024 / 1024).toFixed(1)}MB`
}
/**
* Log a perf measurement to the console in a consistent format.
* Fields are formatted automatically based on their type.
*/
export function logMeasurement(
label: string,
m: PerfMeasurement,
fields: MeasurementField[]
) {
const parts = fields.map((f) => {
const formatter = FIELD_FORMATTERS[f]
if (formatter) return formatter(m)
return `${f}=${m[f]}`
})
console.log(`${label}: ${parts.join(', ')}`)
}
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
const { allFrameDurationsMs: _, ...serializable } = m
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(serializable))
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
}
export function writePerfReport(

View File

@@ -0,0 +1,45 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
/**
* Assertion helper for tests where being in a subgraph is a precondition.
* Throws a clear error if the graph is not a Subgraph.
*/
export function assertSubgraph(
graph: LGraph | Subgraph | null | undefined
): asserts graph is Subgraph {
if (!isSubgraph(graph)) {
throw new Error(
'Expected to be in a subgraph context, but graph is not a Subgraph'
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -29,14 +29,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.steps.goToInputs()
await appMode.goToInputs()
const menu = appMode.select.getInputItemMenu('seed')
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.footer.exitBuilder()
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-menu`
await saveAndReopenInAppMode(comfyPage, workflowName)
@@ -52,11 +52,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.steps.goToInputs()
await appMode.goToInputs()
await appMode.select.renameInput('seed', 'Dblclick Seed')
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
await appMode.footer.exitBuilder()
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName)
@@ -68,14 +68,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.steps.goToPreview()
await appMode.goToPreview()
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.select.renameWidget(menu, 'Preview Seed')
await appMode.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.footer.exitBuilder()
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
@@ -88,13 +88,13 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.footer.exitBuilder()
await appMode.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.select.renameWidget(menu, 'App Mode Seed')
await appMode.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()

View File

@@ -19,26 +19,24 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
.toBe(`*${workflowName} - ComfyUI`)
})
test('Can display workflow name with unsaved changes', async ({
// Failing on CI
// Cannot reproduce locally
test.skip('Can display workflow name with unsaved changes', async ({
comfyPage
}) => {
const workflowName = `test-${Date.now()}`
await comfyPage.menu.topbar.saveWorkflow(workflowName)
await expect
.poll(() => comfyPage.page.title())
.toBe(`${workflowName} - ComfyUI`)
await comfyPage.page.evaluate(async () => {
const node = window.app!.graph!.nodes[0]
node.pos[0] += 50
window.app!.graph!.setDirtyCanvas(true, true)
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker?.checkState()
const workflowName = await comfyPage.page.evaluate(async () => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
})
await expect
.poll(() => comfyPage.page.title())
.toBe(`*${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
await comfyPage.menu.topbar.saveWorkflow('test')
expect(await comfyPage.page.title()).toBe('test - ComfyUI')
const textBox = comfyPage.widgetTextBox
await textBox.fill('Hello World')
await comfyPage.canvasOps.clickEmptySpace()
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {

View File

@@ -2,60 +2,10 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
import { setupBuilder } from '../helpers/builderTestUtils'
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
import { fitToViewInstant } from '../helpers/fitToView'
/**
* Open the save-as dialog, fill name + view type, click save,
* and wait for the success dialog.
*/
async function builderSaveAs(
appMode: AppModeHelper,
workflowName: string,
viewType: 'App' | 'Node graph'
) {
await appMode.footer.saveAsButton.click()
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
await appMode.saveAs.fillAndSave(workflowName, viewType)
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
}
/**
* Load a different workflow, then reopen the named one from the sidebar.
* Caller must ensure the page is in graph mode (not builder or app mode)
* before calling.
*/
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(name).dblclick()
await comfyPage.nextFrame()
await expect(async () => {
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toContain(name)
}).toPass({ timeout: 5000 })
}
/**
* After a first save, open save-as again from the chevron,
* fill name + view type, and save.
*/
async function reSaveAs(
appMode: AppModeHelper,
workflowName: string,
viewType: 'App' | 'Node graph'
) {
await appMode.footer.openSaveAsFromChevron()
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
await appMode.saveAs.fillAndSave(workflowName, viewType)
}
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
@@ -71,301 +21,231 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
})
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
const { saveAs } = comfyPage.appMode
await setupBuilder(comfyPage)
await comfyPage.appMode.footer.saveAsButton.click()
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
await expect(saveAs.nameInput).toBeVisible()
await expect(saveAs.title).toBeVisible()
await expect(saveAs.radioGroup).toBeVisible()
// The save-as dialog should appear with filename input and view type selection
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await expect(dialog.getByRole('textbox')).toBeVisible()
await expect(dialog.getByText('Save as')).toBeVisible()
// View type radio group should be present
const radioGroup = dialog.getByRole('radiogroup')
await expect(radioGroup).toBeVisible()
})
test('Save as dialog allows entering filename and saving', async ({
comfyPage
}) => {
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, `${Date.now()} builder-save`, 'App')
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-save-test`
const input = dialog.getByRole('textbox')
await input.fill(workflowName)
// Save button should be enabled now
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeEnabled()
await saveButton.click()
// Success dialog should appear
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
})
test('Save as dialog disables save when filename is empty', async ({
comfyPage
}) => {
const { saveAs } = comfyPage.appMode
await setupBuilder(comfyPage)
await comfyPage.appMode.footer.saveAsButton.click()
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
await saveAs.nameInput.fill('')
await expect(saveAs.saveButton).toBeDisabled()
})
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
const { saveAs } = comfyPage.appMode
await setupBuilder(comfyPage)
await comfyPage.appMode.footer.saveAsButton.click()
// Clear the filename input
const input = dialog.getByRole('textbox')
await input.fill('')
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
const appRadio = saveAs.viewTypeRadio('App')
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
const graphRadio = saveAs.viewTypeRadio('Node graph')
await graphRadio.click()
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
// Save button should be disabled
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeDisabled()
})
test('Builder step navigation works correctly', async ({ comfyPage }) => {
const { footer } = comfyPage.appMode
await setupBuilder(comfyPage)
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await comfyPage.appMode.steps.goToInputs()
// Should start at outputs (we ended there in setup)
// Navigate to inputs
await appMode.goToInputs()
await expect(footer.backButton).toBeDisabled()
await expect(footer.nextButton).toBeEnabled()
// Back button should be disabled on first step
const backButton = appMode.getFooterButton('Back')
await expect(backButton).toBeDisabled()
await footer.next()
await expect(footer.backButton).toBeEnabled()
// Next button should be enabled
const nextButton = appMode.getFooterButton('Next')
await expect(nextButton).toBeEnabled()
await footer.next()
await expect(footer.nextButton).toBeDisabled()
// Navigate forward
await appMode.next()
// Back button should now be enabled
await expect(backButton).toBeEnabled()
// Navigate to preview (last step)
await appMode.next()
// Next button should be disabled on last step
await expect(nextButton).toBeDisabled()
})
test('Escape key exits builder mode', async ({ comfyPage }) => {
await setupBuilder(comfyPage)
const { page } = comfyPage
await setupSubgraphBuilder(comfyPage)
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
// Verify builder toolbar is visible
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
// Press Escape
await page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
// Builder toolbar should be gone
await expect(toolbar).not.toBeVisible()
})
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
await setupBuilder(comfyPage)
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
await comfyPage.appMode.footer.exitBuilder()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
await appMode.exitBuilder()
await expect(toolbar).not.toBeVisible()
})
test('Save button directly saves for previously saved workflow', async ({
comfyPage
}) => {
const { footer, saveAs } = comfyPage.appMode
await setupBuilder(comfyPage)
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
await saveAs.closeButton.click()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-direct-save`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Modify the workflow so the save button becomes enabled
await comfyPage.appMode.steps.goToInputs()
await comfyPage.appMode.select.deleteInput('seed')
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
// Now click save again — should save directly
await appMode.clickSave()
await footer.saveButton.click()
await comfyPage.nextFrame()
await expect(saveAs.dialog).not.toBeVisible({ timeout: 2000 })
await expect(footer.saveButton).toBeDisabled()
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
})
test('Split button chevron opens save-as for saved workflow', async ({
comfyPage
}) => {
const { footer, saveAs } = comfyPage.appMode
await setupBuilder(comfyPage)
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
await saveAs.closeButton.click()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-split-btn`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
await footer.openSaveAsFromChevron()
// Click the chevron dropdown trigger
const chevronButton = appMode.getFooterButton('Save as')
await chevronButton.click()
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
await expect(saveAs.nameInput).toBeVisible()
// "Save as" menu item should appear
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
await expect(menuItem).toBeVisible({ timeout: 5000 })
await menuItem.click()
// Save-as dialog should appear
const newSaveAsDialog = page.getByRole('dialog')
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
timeout: 5000
})
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
})
test('Connect output popover appears when no outputs selected', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await comfyPage.appMode.enterBuilder()
await appMode.enterBuilder()
await comfyPage.appMode.footer.saveAsButton.click()
// Without selecting any outputs, click the save button
// It should trigger the connect-output popover
await appMode.clickSave()
// The popover should show a message about connecting outputs
await expect(
comfyPage.page.getByText('Connect an output', { exact: false })
page.getByText('Connect an output', { exact: false })
).toBeVisible({ timeout: 5000 })
})
test('save as app produces correct extension and linearMode', async ({
comfyPage
}) => {
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toContain('.app.json')
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
expect(linearMode).toBe(true)
})
// App should be selected by default
const appRadio = dialog.getByRole('radio', { name: /App/ })
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
test('save as node graph produces correct extension and linearMode', async ({
comfyPage
}) => {
await setupBuilder(comfyPage)
await builderSaveAs(
comfyPage.appMode,
`${Date.now()} graph-ext`,
'Node graph'
)
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toMatch(/\.json$/)
expect(path).not.toContain('.app.json')
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
expect(linearMode).toBe(false)
})
test('save as app View App button enters app mode', async ({ comfyPage }) => {
await setupBuilder(comfyPage)
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
await comfyPage.appMode.saveAs.viewAppButton.click()
await comfyPage.nextFrame()
expect(await comfyPage.workflow.getActiveWorkflowActiveAppMode()).toBe(
'app'
)
})
test('save as node graph Exit builder exits builder mode', async ({
comfyPage
}) => {
await setupBuilder(comfyPage)
await builderSaveAs(
comfyPage.appMode,
`${Date.now()} graph-exit`,
'Node graph'
)
await comfyPage.appMode.saveAs.exitBuilderButton.click()
await comfyPage.nextFrame()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
})
test('save as with different mode does not modify the original workflow', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage)
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()
// 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')
// Dismiss success dialog, exit app mode, reopen the original
await appMode.saveAs.dismissButton.click()
await comfyPage.nextFrame()
await appMode.toggleAppMode()
await openWorkflowFromSidebar(comfyPage, originalName)
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
expect(linearMode).toBe(true)
})
test('save as with same name and same mode overwrites in place', async ({
comfyPage
}) => {
const { appMode } = comfyPage
const name = `${Date.now()} overwrite`
await setupBuilder(comfyPage)
await builderSaveAs(appMode, name, 'App')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
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.successMessage).toBeVisible({ timeout: 5000 })
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
expect(pathAfterSecond).toBe(pathAfterFirst)
})
test('save as with same name but different mode creates a new file', async ({
comfyPage
}) => {
const { appMode } = comfyPage
const name = `${Date.now()} mode-change`
await setupBuilder(comfyPage)
await builderSaveAs(appMode, name, 'App')
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
expect(pathAfterFirst).toContain('.app.json')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
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')
})
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 comfyPage.appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, name)
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
expect(mode).toBe('app')
})
test('save as node graph workflow reloads in node graph mode', async ({
comfyPage
}) => {
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 comfyPage.appMode.toggleAppMode()
await openWorkflowFromSidebar(comfyPage, name)
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
expect(mode).toBe('graph')
// Click Node graph option
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
await graphRadio.click()
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
})
})

View File

@@ -1,29 +0,0 @@
import { expect, test } from '@playwright/test'
/**
* Cloud distribution E2E tests.
*
* These tests run against the cloud build (DISTRIBUTION=cloud) and verify
* that cloud-specific behavior is present. In CI, no Firebase auth is
* configured, so the auth guard redirects to /cloud/login. The tests
* verify the cloud build loaded correctly by checking for cloud-only
* routes and elements.
*/
test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
test('cloud build redirects unauthenticated users to login', async ({
page
}) => {
await page.goto('http://localhost:8188')
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
})
test('cloud login page renders sign-in options', async ({ page }) => {
await page.goto('http://localhost:8188')
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
})
})

View File

@@ -256,6 +256,27 @@ test.describe('Missing models in Error Tab', () => {
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
).not.toBeVisible()
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should download missing model when clicking download button', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadAllButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
})
})
test.describe('Settings', () => {

View File

@@ -1,126 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.queuePanel.overlayToggle.click()
})
test('Dialog opens from queue panel history actions menu', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
})
test('Dialog shows confirmation message with title, description, and assets note', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('Clear your job queue history?')
).toBeVisible()
await expect(
dialog.getByText(
'All the finished or failed jobs below will be removed from this Job queue panel.'
)
).toBeVisible()
await expect(
dialog.getByText(
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
)
).toBeVisible()
})
test('Cancel button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Close (X) button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.getByLabel('Close').click()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Confirm clears queue history and closes dialog', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
const clearPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes('/api/history') && req.method() === 'POST'
)
await dialog.getByRole('button', { name: 'Clear' }).click()
const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog).not.toBeVisible()
})
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog).toBeVisible()
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeEnabled()
})
})

View File

@@ -1,94 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { SignInDialog } from '../../fixtures/components/SignInDialog'
test.describe('Sign In dialog', { tag: '@ui' }, () => {
let dialog: SignInDialog
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
dialog = new SignInDialog(comfyPage.page)
await dialog.open()
})
test('Should open and show the sign-in form by default', async () => {
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
await expect(dialog.emailInput).toBeVisible()
await expect(dialog.passwordInput).toBeVisible()
await expect(dialog.signInButton).toBeVisible()
})
test('Should toggle from sign-in to sign-up form', async () => {
await dialog.signUpLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Create an account' })
).toBeVisible()
await expect(dialog.signUpEmailInput).toBeVisible()
await expect(dialog.signUpPasswordInput).toBeVisible()
await expect(dialog.signUpConfirmPasswordInput).toBeVisible()
await expect(dialog.signUpButton).toBeVisible()
})
test('Should toggle back from sign-up to sign-in form', async () => {
await dialog.signUpLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Create an account' })
).toBeVisible()
await dialog.signInLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
await expect(dialog.emailInput).toBeVisible()
await expect(dialog.passwordInput).toBeVisible()
})
test('Should navigate to the API Key form and back', async () => {
await dialog.apiKeyButton.click()
await expect(dialog.apiKeyHeading).toBeVisible()
await expect(dialog.apiKeyInput).toBeVisible()
await dialog.backButton.click()
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
})
test('Should display Terms of Service and Privacy Policy links', async () => {
await expect(dialog.termsLink).toBeVisible()
await expect(dialog.termsLink).toHaveAttribute(
'href',
'https://www.comfy.org/terms-of-service'
)
await expect(dialog.privacyLink).toBeVisible()
await expect(dialog.privacyLink).toHaveAttribute(
'href',
'https://www.comfy.org/privacy'
)
})
test('Should display the "Or continue with" divider and API key button', async () => {
await expect(dialog.dividerText).toBeVisible()
await expect(dialog.apiKeyButton).toBeVisible()
})
test('Should show forgot password link on sign-in form', async () => {
await expect(dialog.forgotPasswordLink).toBeVisible()
})
test('Should close dialog via close button', async () => {
await dialog.close()
await expect(dialog.root).toBeHidden()
})
test('Should close dialog via Escape key', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).toBeHidden()
})
})

View File

@@ -55,4 +55,46 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
const finalCount = await comfyPage.getDOMWidgetCount()
expect(finalCount).toBe(initialCount + 1)
})
test('should reposition when layout changes', async ({ comfyPage }) => {
test.skip(
true,
'Only recalculates when the Canvas size changes, need to recheck the logic'
)
// --- setup ---
const textareaWidget = comfyPage.page
.locator('.comfy-multiline-input')
.first()
await expect(textareaWidget).toBeVisible()
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.nextFrame()
let oldPos: [number, number]
const checkBboxChange = async () => {
const boudningBox = (await textareaWidget.boundingBox())!
expect(boudningBox).not.toBeNull()
const position: [number, number] = [boudningBox.x, boudningBox.y]
expect(position).not.toEqual(oldPos)
oldPos = position
}
await checkBboxChange()
// --- test ---
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Bottom')
await comfyPage.nextFrame()
await checkBboxChange()
})
})

View File

@@ -94,7 +94,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
.click()
})
})
test(
// The 500ms fixed delay on the search results is causing flakiness
// Potential solution: add a spinner state when the search is in progress,
// and observe that state from the test. Blocker: the PrimeVue AutoComplete
// does not have a v-model on the query, so we cannot observe the raw
// query update, and thus cannot set the spinning state between the raw query
// update and the debounced search update.
test.skip(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
@@ -102,16 +108,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(groupNodeName)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${groupNodeName}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -175,9 +175,7 @@ test.describe('Node Interaction', () => {
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
maxDiffPixels: 50
})
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
@@ -222,7 +220,10 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
})
test('Can copy link by shift-drag existing link', async ({ comfyPage }) => {
// Shift drag copy link regressed. See https://github.com/Comfy-Org/ComfyUI_frontend/issues/2941
test.skip('Can copy link by shift-drag existing link', async ({
comfyPage
}) => {
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
@@ -814,15 +815,11 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await expect
.poll(() => comfyPage.menu.topbar.getTabNames(), { timeout: 5000 })
.toEqual(expect.arrayContaining([workflowA, workflowB]))
const tabs = await comfyPage.menu.topbar.getTabNames()
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -68,7 +68,7 @@ test.describe(
})
})
test('Load workflow from URL dropped onto Vue node', async ({
test.fixme('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {
const fakeUrl = 'https://example.com/workflow.png'

View File

@@ -78,66 +78,4 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimapContainer).toBeVisible()
})
test('Close button hides minimap', async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).not.toBeVisible()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
})
test(
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.scale = 3
canvas.ds.offset[0] = -800
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
}
)
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
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(minimap).toHaveScreenshot('minimap-with-viewport.png')
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -481,7 +481,6 @@ This is English documentation.
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.waitFor({ state: 'visible', timeout: 10_000 })
await helpButton.click()
const helpPage = comfyPage.page.locator(

View File

@@ -176,13 +176,40 @@ test.describe('Node search box', { tag: '@node' }, () => {
await expectFilterChips(comfyPage, ['MODEL'])
})
test('Outer click dismisses filter panel but keeps search box visible', async ({
// Flaky test.
// Sample test failure:
// https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/12696912248/job/35391990861?pr=2210
/*
1) [chromium-2x] nodeSearchBox.spec.ts:135:5 Node search box Filtering Outer click dismisses filter panel but keeps search box visible
Error: expect(locator).not.toBeVisible()
Locator: getByRole('dialog').locator('div').filter({ hasText: 'Add node filter condition' })
Expected: not visible
Received: visible
Call log:
- expect.not.toBeVisible with timeout 5000ms
- waiting for getByRole('dialog').locator('div').filter({ hasText: 'Add node filter condition' })
143 |
144 | // Verify the filter selection panel is hidden
> 145 | expect(panel.header).not.toBeVisible()
| ^
146 |
147 | // Verify the node search dialog is still visible
148 | expect(comfyPage.searchBox.input).toBeVisible()
at /home/runner/work/ComfyUI_frontend/ComfyUI_frontend/ComfyUI_frontend/browser_tests/nodeSearchBox.spec.ts:145:32
*/
test.skip('Outer click dismisses filter panel but keeps search box visible', async ({
comfyPage
}) => {
await comfyPage.searchBox.filterButton.click()
const panel = comfyPage.searchBox.filterSelectionPanel
await panel.header.waitFor({ state: 'visible' })
await comfyPage.page.keyboard.press('Escape')
const panelBounds = await panel.header.boundingBox()
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
// Verify the filter selection panel is hidden
await expect(panel.header).not.toBeVisible()

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { logMeasurement, recordMeasurement } from '../helpers/perfReporter'
import { recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {
@@ -186,22 +186,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph viewport pan sweep', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.perf.startMeasuring()
await comfyPage.canvasOps.panSweep()
const measurement = await comfyPage.perf.stopMeasuring('viewport-pan-sweep')
recordMeasurement(measurement)
logMeasurement('Viewport pan sweep', measurement, [
'styleRecalcs',
'layouts',
'taskDurationMs',
'heapDeltaBytes',
'domNodes'
])
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

View File

@@ -1,34 +0,0 @@
# Properties Panel E2E Tests
Tests for the right-side properties panel (`RightSidePanel.vue`).
## Structure
| File | Coverage |
| --------------------------------- | ----------------------------------------------------------- |
| `openClose.spec.ts` | Panel open/close via actionbar and close button |
| `workflowOverview.spec.ts` | No-selection state: tabs, nodes list, global settings |
| `nodeSelection.spec.ts` | Single/multi-node selection, selection changes, tab labels |
| `titleEditing.spec.ts` | Node title editing via pencil icon |
| `searchFiltering.spec.ts` | Widget search and clear |
| `nodeSettings.spec.ts` | Settings tab: node state, color, pinned (requires VueNodes) |
| `infoTab.spec.ts` | Node help content |
| `errorsTab.spec.ts` | Errors tab visibility |
| `propertiesPanelPosition.spec.ts` | Panel position relative to sidebar |
## Shared Helper
`PropertiesPanelHelper.ts` — Encapsulates panel locators and actions. Instantiated in `beforeEach`:
```typescript
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
```
## Conventions
- Tests requiring VueNodes rendering enable it in `beforeEach` via `comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)` and call `comfyPage.vueNodes.waitForNodes()`.
- Verify node state changes via user-facing indicators (text labels like "Bypassed"/"Muted", pin indicator test IDs) rather than internal properties.
- Color changes are verified via `page.evaluate` accessing node properties, per the guidance in `docs/guidance/playwright.md`.

View File

@@ -1,100 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TestIds } from '../../fixtures/selectors'
export class PropertiesPanelHelper {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
readonly closeButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.closeButton = this.root.locator('button[aria-pressed]')
}
get tabs(): Locator {
return this.root.locator('nav button')
}
getTab(label: string): Locator {
return this.root.locator('nav button', { hasText: label })
}
get titleEditIcon(): Locator {
return this.panelTitle.locator('i[class*="lucide--pencil"]')
}
get titleInput(): Locator {
return this.root.getByTestId(TestIds.node.titleInput)
}
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
return this.root.locator('button', { hasText: state })
}
getColorSwatch(colorName: string): Locator {
return this.root.locator(`[data-testid="${colorName}"]`)
}
get pinnedSwitch(): Locator {
return this.root.locator('[data-p-checked]').first()
}
get subgraphEditButton(): Locator {
return this.root.locator('button:has(i[class*="lucide--settings-2"])')
}
get contentArea(): Locator {
return this.root.locator('.scrollbar-thin')
}
get errorsTabIcon(): Locator {
return this.root.locator('nav i[class*="lucide--octagon-alert"]')
}
get viewAllSettingsButton(): Locator {
return this.root.getByRole('button', { name: /view all settings/i })
}
get collapseToggleButton(): Locator {
return this.root.locator(
'button:has(i[class*="lucide--chevrons-down-up"]), button:has(i[class*="lucide--chevrons-up-down"])'
)
}
async open(actionbar: Locator): Promise<void> {
if (!(await this.root.isVisible())) {
await actionbar.click()
await expect(this.root).toBeVisible()
}
}
async close(): Promise<void> {
if (await this.root.isVisible()) {
await this.closeButton.click()
await expect(this.root).not.toBeVisible()
}
}
async switchToTab(label: string): Promise<void> {
await this.getTab(label).click()
}
async editTitle(newTitle: string): Promise<void> {
await this.titleEditIcon.click()
await this.titleInput.fill(newTitle)
await this.titleInput.press('Enter')
}
async searchWidgets(query: string): Promise<void> {
await this.searchBox.fill(query)
}
async clearSearch(): Promise<void> {
await this.searchBox.fill('')
}
}

View File

@@ -1,31 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Errors tab', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
await expect(panel.errorsTabIcon).toBeVisible()
})
test('should not show Errors tab when errors are disabled', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.errorsTabIcon).not.toBeVisible()
})
})

View File

@@ -1,22 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Info tab', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await panel.switchToTab('Info')
})
test('should show node help content', async () => {
await expect(panel.contentArea).toBeVisible()
await expect(
panel.contentArea.getByRole('heading', { name: 'Inputs' })
).toBeVisible()
})
})

View File

@@ -1,126 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Node selection', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
})
test.describe('Single node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
})
test('should show node title in panel header', async () => {
await expect(panel.panelTitle).toContainText('KSampler')
})
test('should show Parameters, Info, and Settings tabs', async () => {
await expect(panel.getTab('Parameters')).toBeVisible()
await expect(panel.getTab('Info')).toBeVisible()
await expect(panel.getTab('Settings')).toBeVisible()
})
test('should not show Nodes tab for single node', async () => {
await expect(panel.getTab('Nodes')).not.toBeVisible()
})
test('should display node widgets in Parameters tab', async () => {
await expect(panel.contentArea.getByText('seed')).toBeVisible()
await expect(panel.contentArea.getByText('steps')).toBeVisible()
})
})
test.describe('Multi-node', () => {
test('should show item count in title', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.panelTitle).toContainText('3 items selected')
})
test('should list all selected nodes in Parameters tab', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(
panel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
test('should not show Info tab for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.getTab('Info')).not.toBeVisible()
})
})
test.describe('Selection changes', () => {
test('should update from no selection to node selection', async ({
comfyPage
}) => {
await expect(panel.panelTitle).toContainText('Workflow Overview')
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
})
test('should update from node selection back to no selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await expect(panel.panelTitle).toContainText('Workflow Overview')
})
test('should update between different single node selections', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['Empty Latent Image'])
await expect(panel.panelTitle).toContainText('Empty Latent Image')
})
})
test.describe('Tab labels', () => {
test('should show "Parameters" tab for single node', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.getTab('Parameters')).toBeVisible()
})
test('should show "Nodes" tab label for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.getTab('Nodes')).toBeVisible()
})
})
})

View File

@@ -1,122 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Node settings', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await panel.switchToTab('Settings')
})
test.describe('Node state', () => {
test('should show Normal, Bypass, and Mute state buttons', async () => {
await expect(panel.getNodeStateButton('Normal')).toBeVisible()
await expect(panel.getNodeStateButton('Bypass')).toBeVisible()
await expect(panel.getNodeStateButton('Mute')).toBeVisible()
})
test('should set node to Bypass mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Bypass').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
})
test('should set node to Mute mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Mute').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Muted')).toBeVisible()
})
test('should restore node to Normal mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Bypass').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
await panel.getNodeStateButton('Normal').click()
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
})
})
test.describe('Node color', () => {
test('should display color swatches', async () => {
await expect(panel.getColorSwatch('noColor')).toBeVisible()
await expect(panel.getColorSwatch('red')).toBeVisible()
await expect(panel.getColorSwatch('blue')).toBeVisible()
})
test('should apply color to node', async ({ comfyPage }) => {
await panel.getColorSwatch('red').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color != null
})
)
.toBe(true)
})
test('should remove color with noColor swatch', async ({ comfyPage }) => {
await panel.getColorSwatch('red').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color != null
})
)
.toBe(true)
await panel.getColorSwatch('noColor').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color
})
)
.toBeFalsy()
})
})
test.describe('Pinned state', () => {
test('should display pinned toggle', async () => {
await expect(panel.pinnedSwitch).toBeVisible()
})
test('should toggle pinned state', async ({ comfyPage }) => {
await panel.pinnedSwitch.click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
})
test('should unpin previously pinned node', async ({ comfyPage }) => {
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await panel.pinnedSwitch.click()
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
await panel.pinnedSwitch.click()
await expect(
nodeLocator.getByTestId('node-pin-indicator')
).not.toBeVisible()
})
})
})

View File

@@ -1,32 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Open and close', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should open via actionbar toggle button', async ({ comfyPage }) => {
await expect(panel.root).not.toBeVisible()
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
test('should close via panel close button', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.closeButton.click()
await expect(panel.root).not.toBeVisible()
})
test('should close via close button after opening', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.close()
await expect(panel.root).not.toBeVisible()
})
})

View File

@@ -0,0 +1,36 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Properties panel', () => {
test('opens and updates title based on selection', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
await propertiesPanel.searchBox.fill('seed')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(0)
await propertiesPanel.searchBox.fill('')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
})

View File

@@ -1,41 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Search filtering', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
})
test('should filter nodes by search query', async () => {
await panel.searchWidgets('seed')
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
0
)
})
test('should restore all nodes when search is cleared', async () => {
await panel.searchWidgets('seed')
await panel.clearSearch()
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
2
)
})
test('should show empty state for no matches', async () => {
await panel.searchWidgets('nonexistent_widget_xyz')
await expect(
panel.contentArea.getByText(/no .* match|no results|no items/i)
).toBeVisible()
})
})

View File

@@ -1,50 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Title editing', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
})
test('should show pencil icon for editable title', async () => {
await expect(panel.titleEditIcon).toBeVisible()
})
test('should enter edit mode on pencil click', async () => {
await panel.titleEditIcon.click()
await expect(panel.titleInput).toBeVisible()
})
test('should update node title on edit', async () => {
const newTitle = 'My Custom Sampler'
await panel.editTitle(newTitle)
await expect(panel.panelTitle).toContainText(newTitle)
})
test('should not show pencil icon for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.titleEditIcon).not.toBeVisible()
})
test('should not show pencil icon when nothing is selected', async ({
comfyPage
}) => {
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

@@ -1,70 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Workflow Overview', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
test('should show "Workflow Overview" title when nothing is selected', async () => {
await expect(panel.panelTitle).toContainText('Workflow Overview')
})
test('should show Parameters, Nodes, and Settings tabs', async () => {
await expect(panel.getTab('Parameters')).toBeVisible()
await expect(panel.getTab('Nodes')).toBeVisible()
await expect(panel.getTab('Settings')).toBeVisible()
})
test('should not show Info tab when nothing is selected', async () => {
await expect(panel.getTab('Info')).not.toBeVisible()
})
test('should switch to Nodes tab and list all workflow nodes', async ({
comfyPage
}) => {
await panel.switchToTab('Nodes')
const nodeCount = await comfyPage.nodeOps.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
})
test('should filter nodes by search in Nodes tab', async () => {
await panel.switchToTab('Nodes')
await panel.searchWidgets('KSampler')
await expect(panel.contentArea.getByText('KSampler').first()).toBeVisible()
})
test('should switch to Settings tab and show global settings', async () => {
await panel.switchToTab('Settings')
await expect(panel.viewAllSettingsButton).toBeVisible()
})
test('should show "View all settings" button', async () => {
await panel.switchToTab('Settings')
await expect(panel.viewAllSettingsButton).toBeVisible()
})
test('should show Nodes section with toggles', async () => {
await panel.switchToTab('Settings')
await expect(
panel.contentArea.getByRole('button', { name: 'NODES' })
).toBeVisible()
})
test('should show Canvas section with grid settings', async () => {
await panel.switchToTab('Settings')
await expect(panel.contentArea.getByText('Canvas')).toBeVisible()
})
test('should show Connection Links section', async () => {
await panel.switchToTab('Settings')
await expect(panel.contentArea.getByText('Connection Links')).toBeVisible()
})
})

View File

@@ -1,23 +1,8 @@
import { expect } from '@playwright/test'
import type { components } from '@comfyorg/registry-types'
type ReleaseNote = components['schemas']['ReleaseNote']
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
function createMockRelease(overrides?: Partial<ReleaseNote>): ReleaseNote {
return {
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString(),
...overrides
}
}
test.describe('Release Notifications', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -37,10 +22,15 @@ test.describe('Release Notifications', () => {
status: 200,
contentType: 'application/json',
body: JSON.stringify([
createMockRelease({
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content:
'## New Features\n\n- Added awesome feature\n- Fixed important bug'
})
'## New Features\n\n- Added awesome feature\n- Fixed important bug',
published_at: new Date().toISOString()
}
])
})
} else {
@@ -167,7 +157,16 @@ test.describe('Release Notifications', () => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([createMockRelease({ attention: 'high' })])
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'high',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
@@ -251,7 +250,16 @@ test.describe('Release Notifications', () => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([createMockRelease()])
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
@@ -295,10 +303,14 @@ test.describe('Release Notifications', () => {
status: 200,
contentType: 'application/json',
body: JSON.stringify([
createMockRelease({
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'low',
content: '## Bug Fixes\n\n- Fixed minor issue'
})
content: '## Bug Fixes\n\n- Fixed minor issue',
published_at: new Date().toISOString()
}
])
})
} else {

View File

@@ -0,0 +1,92 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { measureSelectionBounds } from '../fixtures/utils/boundsUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
const REF_POS: [number, number] = [100, 100]
const TARGET_POSITIONS: Record<string, [number, number]> = {
'bottom-left': [50, 500],
'bottom-right': [600, 500]
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
test.describe('Selection bounding box', { tag: ['@canvas', '@node'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeTypes: NodeType[] = ['subgraph', 'regular']
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const type of nodeTypes) {
for (const state of nodeStates) {
for (const pos of positions) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const page = comfyPage.page
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: REF_POS,
[targetId]: TARGET_POSITIONS[pos]
})
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await nodeRef.setCollapsed(true)
}
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, [refId, targetId])
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const vis = result.nodeVisualBounds[targetId]
expect(vis).toBeDefined()
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
const visRight = vis.x + vis.w
const visBottom = vis.y + vis.h
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(visRight)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(visBottom)
})
}
}
}
})

View File

@@ -271,11 +271,9 @@ test.describe('Workflows sidebar', () => {
'.comfyui-workflows-open .close-workflow-button'
)
await closeButton.click()
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames(), {
timeout: 5000
})
.toEqual(['*Unsaved Workflow'])
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow'
])
})
test('Can close saved workflow with command', async ({ comfyPage }) => {

View File

@@ -0,0 +1,125 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
// TODO: Extract allNodeIds accessor into LGraph
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})
test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]
const isNonNegative = (id: number | string) =>
typeof id === 'number' && id >= 0
return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
isNonNegative(link.origin_id) &&
!g._nodes_by_id[link.origin_id] &&
`${label}: origin_id ${link.origin_id} not found`,
isNonNegative(link.target_id) &&
!g._nodes_by_id[link.target_id] &&
`${label}: target_id ${link.target_id} not found`
].filter(Boolean)
)
)
})
expect(invalidLinks).toEqual([])
})
test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
const isInSubgraph = () =>
comfyPage.page.evaluate(
() => window.app!.canvas.graph?.isRootGraph === false
)
expect(await isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph()).toBe(false)
})
})

View File

@@ -0,0 +1,86 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

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