Compare commits

...

83 Commits

Author SHA1 Message Date
GitHub Action
702f47f3e1 [automated] Apply ESLint and Oxfmt fixes 2026-04-16 12:45:36 +00:00
pythongosssss
0da4362205 fix: show Comfy logo for core nodes even when essentials_category is set
Check python_module against CORE_NODE_MODULES directly for badge display
so that core nodes like PreviewImage/SaveImage show the Comfy logo instead
of being misclassified as essentials.
2026-04-16 05:39:30 -07:00
pythongosssss
9363d31ad4 support adding subgraphs as ghost 2026-04-16 05:21:19 -07:00
pythongosssss
955427b59f fix including non-core essential nodes as "comfy" 2026-04-16 05:21:19 -07:00
pythongosssss
e181339159 fix showing empty categories with trailing or double slashes 2026-04-16 05:21:19 -07:00
pythongosssss
e4059303d4 expand and simplify tests 2026-04-15 05:03:06 -07:00
GitHub Action
5796c6d464 [automated] Apply ESLint and Oxfmt fixes 2026-04-14 18:15:58 +00:00
pythongosssss
dce8b87759 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-04-13 03:58:56 -07:00
Kelly Yang
bd82c855e0 test: add minimap E2E tests for graph content and click-to-navigate (#10738)
## Summary

Adds Playwright E2E tests verifying that 
1. the minimap canvas renders node content
2. clears when the graph is empty
3. correctly navigates the main canvas on click

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10738-test-add-minimap-E2E-tests-for-graph-content-and-click-to-navigate-3336d73d365081eb955ce711b3efc57f)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to adding `data-testid` attributes to
the minimap UI and expanding Playwright E2E assertions, with no
production behavior changes expected.
> 
> **Overview**
> Strengthens minimap E2E coverage by switching existing assertions from
CSS selectors to new `data-testid`-based selectors and adding helper
utilities for canvas/overlay interactions.
> 
> Adds new Playwright tests that verify the minimap canvas renders
content when nodes exist, clears when the graph is emptied, and that
clicking the minimap pans the main canvas (including a
post-`fitViewToSelectionAnimated` tolerance check).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
06e7542af1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-13 04:28:04 +00:00
Kelly Yang
5b7ef3fe21 test: Painter Widget E2E Test Plan (#10846)
### Summary of Improvements

* **Custom Test Coverage Extension**: Enhanced the Painter widget E2E
test suite by refactoring logic for better maintainability and
robustness.
* **Stable Component Targeting**: Introduced
`data-testid="painter-dimension-text"` to `WidgetPainter.vue`, providing
a reliable, non-CSS-dependent locator for canvas size verification.
* **Improved Test Organization**: Reorganized existing test scenarios
into logical categories using `test.describe` blocks (Drawing, Brush
Settings, Canvas Size Controls, etc.).
* **Asynchronous Helper Integration**: Converted `hasCanvasContent` to
an asynchronous helper and unified its usage across multiple test cases
to eliminate redundant pixel-checking logic.
* **Locator Resilience**: Updated Reka UI slider interaction logic to
use more precise targeting (`:not([data-slot])`), preventing ambiguity
and improving test stability.
* **Scenario Refinement**: Updated the `pointerup` test logic to
accurately reflect pointer capture behavior when interactions occur
outside the canvas boundaries.
* **Enhanced Verification Feedback**: Added descriptive error messages
to `expect.poll` assertions to provide clearer context on potential
failure points.
* **Standardized Tagging**: Restored the original tagging strategy
(including `@smoke` and `@screenshot` tags) to ensure tests are
categorized correctly for CI environments.

### Red-Green Verification

| Commit | CI Status | Purpose |
| :--- | :--- | :--- |
| `test: refactor painter widget e2e tests and address review findings`
| 🟢 Green | Addresses all E2E test quality and stability issues from
review findings. |

### Test Plan

- [x] **Quality Checks**: `pnpm format`, `pnpm lint`, and `pnpm
typecheck` verified as passing.
- [x] **Component Integration**: `WidgetPainter.vue` `data-testid`
correctly applied and used in tests.
- [x] **Helper Reliability**: `hasCanvasContent` correctly identifies
colored pixels and returns a promise for `expect.poll`.
- [x] **Locator Robustness**: Verified Reka slider locators correctly
exclude internal thumb spans.
- [x] **Boundary Interaction**: Verified `pointerup` correctly ends
strokes when triggered outside the viewport.
- [x] **Tagging Consistency**: Verified `@smoke` and `@screenshot` tags
are present in the final test suite.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10846-test-Painter-Widget-E2E-Test-Plan-3386d73d365081deb70fe4afbd417efb)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily adds/refactors Playwright E2E tests and stable `data-testid`
hooks, with no changes to Painter drawing logic. Risk is limited to
potential test brittleness or minor UI attribute changes.
> 
> **Overview**
> Expands the Painter widget Playwright suite with new grouped scenarios
covering drawing/erasing behavior, tool switching, brush inputs, canvas
resizing (including preserving drawings), clear behavior, and
serialization/upload flows (including failure toast).
> 
> Refactors the tests to use a shared `@e2e/helpers/painter` module
(`drawStroke`, `hasCanvasContent`, `triggerSerialization`), improves
stability via role/testid-based locators and clearer `expect.poll`
messaging, and adds `data-testid` attributes (e.g.,
`painter-clear-button`, `painter-*-row`, `painter-dimension-text`) to
`WidgetPainter.vue` to avoid CSS-dependent selectors.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
053a8e9ed2. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-13 00:13:04 -04:00
Kelly Yang
85de833776 test: add E2E tests for ImageCompare widget (#10767)
## Summary
Add E2E tests for ImageCompare widget
Covers slider interaction, batch navigation, single-image modes, visual
regression screenshots, and edge cases for the ImageCompare Vue node
widget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10767-test-add-E2E-tests-for-ImageCompare-widget-3346d73d365081c6bfc6fbd97fa04e4d)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Adds Playwright E2E coverage and screenshot assertions only; main risk
is increased CI runtime/flakiness due to additional image-loading and
hover/position polling.
> 
> **Overview**
> Adds a new Playwright E2E suite for the ImageCompare Vue widget
(tagged `@widget`) that programmatically sets widget values and asserts
rendering for empty, single-image, and dual-image states.
> 
> Expands coverage to **slider behavior** (default 50%, hover movement,
clamping, persistence) using polling on inline `clip-path`/handle
position, and adds **batch navigation** tests for multi-image
before/after sets.
> 
> Introduces **visual regression screenshots** at default and specific
slider positions, plus edge-case tests for broken URLs, rapid updates
resetting batch index, legacy string values, and custom alt text.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
2c65440384. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-13 00:09:50 -04:00
Kelly Yang
cab46567c0 test: add E2E tests for ImageCropV2 widget (#10737)
## Summary
Adds Playwright E2E tests for the ImageCropV2 widget covering 
1. the empty state (no source image)
2. default control rendering
3. source image display with crop overlay
4. drag-to-reposition behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10737-test-add-E2E-tests-for-ImageCropV2-widget-3336d73d365081b28ed9db63e5df383e)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: primarily adds Playwright E2E coverage and introduces
`data-testid` attributes for more stable selectors, with no changes to
core crop behavior.
> 
> **Overview**
> Adds new Playwright E2E coverage for the `ImageCropV2` Vue-node
widget, including workflows/fixtures for a disconnected input and a
`LoadImage -> ImageCropV2 -> PreviewImage` pipeline.
> 
> Tests validate the empty state and default controls, verify the crop
overlay renders after execution with screenshot assertions, and exercise
drag-to-reposition by dispatching pointer events and asserting the
widget’s crop value updates.
> 
> Updates `WidgetImageCrop.vue` to add `data-testid` hooks (empty
state/icon and crop overlay) to make the E2E selectors stable.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9f29272742. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-04-12 23:58:01 -04:00
Comfy Org PR Bot
63435bdb34 1.44.3 (#11170)
Patch version increment to 1.44.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11170-1-44-3-3406d73d365081799aa4e189009d123b)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-12 23:11:20 +00:00
Kelly Yang
20255da61f feat(load3d): add optional HDRI environment lighting to 3D preview nodes (#10818)
## Summary

Adds `HDRIManager` to load `.hdr/.exr` files as equirectangular
environment maps via **three.js** `RGBELoader/EXRLoader`
- Uploads HDRI files to the server via `/upload/image` API so they
persist across page reloads
- Restores HDRI state (enabled, **intensity**, **background**) from node
properties on reload
- Auto-enables "**Show as Background**" on successful upload for
immediate visual feedback
- Hides standard directional lights when HDRI is active; restores them
when disabled
- Hides the Light Intensity control while HDRI is active (lights have no
effect when HDRI overrides scene lighting)
- Limits HDRI availability to PBR-capable formats (.gltf, .glb, .fbx,
.obj); automatically disables when switching to an incompatible model
- Adds intensity slider and "**Show as Background**" toggle to the HDRI
panel

## How to Use HDRI Environment Lighting
1. Load a 3D model using a Load3D or Load3DViewer node (supported
formats: .gltf, .glb, .fbx, .obj)
2. Open the control panel → go to the Light tab
3. Click the globe icon to open the **HDRI panel**
4. Click Upload HDRI and select a` .hdr` or `.exr` file
5. The environment lighting applies automatically — the scene background
also updates to preview the panorama
6. Use the intensity slider to adjust the strength of the environment
lighting
7. Toggle Show as Background to show or hide the HDRI panorama behind
the model

## Screenshots



https://github.com/user-attachments/assets/1ec56ef0-853e-452f-ae2b-2474c9d0d781



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10818-feat-load3d-add-optional-HDRI-environment-lighting-to-3D-preview-nodes-3366d73d365081ea8c7ad9226b8b1e2f)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new HDRI loading/rendering path and persists new
`LightConfig.hdri` state, touching Three.js rendering, file uploads, and
node property restoration. Risk is moderate due to new async flows and
potential compatibility/performance issues with model switching and
renderer settings.
> 
> **Overview**
> Adds optional **HDRI environment lighting** to Load3D previews,
including a new `HDRIManager` that loads `.hdr`/`.exr` files into
Three.js environment/background and exposes controls for enable/disable,
background display, and intensity.
> 
> Extends `LightConfig` with an `hdri` block that is persisted on nodes
and restored on reload; `useLoad3d` now uploads HDRI files, loads them
into `Load3d`, maps scene light intensity to HDRI intensity, and
auto-disables HDRI when the current model format doesn’t support it.
> 
> Updates the UI to include embedded HDRI controls under the Light panel
(with dismissable overlays and icon updates), adjusts light intensity
behavior when HDRI is active, and adds tests/strings/utilities
(`getFilenameExtension`, `mapSceneLightIntensityToHdri`, new constants)
to support the feature.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b12c9722dc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-12 05:55:48 -04:00
Christian Byrne
c2dba8f4ee chore(#11080): consolidate duplicate rgbToHSL — use shared colorUtil (#11134)
## Summary

Consolidate duplicate `rgbToHSL` implementation — mask editor now uses
the shared `colorUtil.ts` version instead of its own copy.

## Changes

- Export `rgbToHsl` from `src/utils/colorUtil.ts` (was private)
- Replace 30-line local `rgbToHSL` in `useCanvasTools.ts` with a 2-line
wrapper that imports from `colorUtil.ts` and scales the return values
from 0-1 to degree/percentage

## Testing

### Automated

- All 176 existing tests pass (`colorUtil.test.ts` + `maskeditor/`
suite)
- No new tests needed — behavior is identical

### E2E Verification Steps

1. Open any image in the mask editor
2. Select the magic wand / color picker tool
3. Use HSL-based color matching — results should be identical to before

## Review Focus

The canonical `rgbToHsl` returns normalized 0-1 values while the mask
editor needs degree/percentage scale (h: 0-360, s: 0-100, l: 0-100). The
local wrapper handles this conversion.

Fixes #11080

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11134-chore-11080-consolidate-duplicate-rgbToHSL-use-shared-colorUtil-33e6d73d36508120bbd8f444f5cc94b6)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-12 01:40:55 +00:00
Alexander Brown
6f579c5992 fix: enable playwright/no-force-option lint rule (#11164)
## Summary

Enable the previously disabled `playwright/no-force-option` lint rule at
error level and resolve all 29 violations across 10 files.

## Changes

### Lint rule
- `.oxlintrc.json`: `playwright/no-force-option` changed from `off` to
`error`

### Shared utility
- `CanvasHelper.ts`: Add `mouseClickAt()` and `mouseDblclickAt()`
methods that convert canvas-element-relative positions to absolute page
coordinates and use `page.mouse` APIs, avoiding Playwright's locator
actionability checks that fail when Vue DOM overlays sit above the
`<canvas>` element

### Force removal (20 violations)
- `selectionToolboxActions.spec.ts`: Remove `force: true` from 8 toolbox
button clicks (the `pointer-events: none` splitter overlay does not
intercept `elementFromPoint()`)
- `selectionToolboxSubmenus.spec.ts`: Remove `force: true` from 2
popover menu item clicks
- `BuilderSelectHelper.ts`: Remove `force: true` from 2 widget/node
clicks (builder mode does not disable pointer events)
- `linkInteraction.spec.ts`: Remove `force: true` from 3 slot `dragTo()`
calls (`::after` pseudo-elements do not intercept `elementFromPoint()`)
- `SidebarTab.ts`: Remove `force: true` from toast dismissal (`.catch()`
already handles failures)
- `nodeHelp.spec.ts`: Remove `force: true` from info button click
(preceding `toBeVisible()` assertion is sufficient)

### Rewrites (3 violations)
- `integerWidget.spec.ts`: Replace force-clicking disabled buttons with
`toBeDisabled()` assertions
- `Topbar.ts`: Replace force-click with `waitFor({ state: 'visible' })`
after hover

### Canvas coordinate clicks (9 violations)
- `litegraphUtils.ts`: Convert `NodeReference.click()` and
`navigateIntoSubgraph()` to use
`canvasOps.mouseClickAt()`/`mouseDblclickAt()`
- `subgraphPromotion.spec.ts`: Convert 3 right-click canvas calls to
`canvasOps.mouseClickAt()`
- `selectionToolboxSubmenus.spec.ts`: Convert 1 canvas dismiss-click to
`canvasOps.mouseClickAt()`

## Rationale

The original `force: true` usages were added defensively based on
incorrect assumptions about the `z-999 pointer-events: none` splitter
overlay intercepting Playwright's actionability checks. In reality,
`elementFromPoint()` skips elements with `pointer-events: none`, so the
overlay is transparent to Playwright's hit-test.

For canvas coordinate clicks, `force: true` on a locator does not tunnel
through DOM overlays — it only skips Playwright's preflight checks.
`page.mouse.click()` is the correct API for coordinate-based canvas
interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11164-fix-enable-playwright-no-force-option-lint-rule-33f6d73d365081e78601c6114121d272)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-11 19:59:34 +00:00
Dante
e729e5edb8 fix: place cloned node above original in Vue renderer (#10361)
## Summary

Cloned/pasted nodes in Node 2.0 (Vue renderer) mode now appear above the
original node instead of behind it.

## Root Cause

The legacy LiteGraph canvas renderer uses array ordering for z-ordering:
nodes are stored in `graph._nodes` and drawn sequentially, so newly
added nodes (appended to the end) are automatically drawn on top. There
is no explicit z-index.

The Vue renderer (Node 2.0) uses explicit CSS `z-index` for node
ordering. New nodes default to `zIndex: 0` in `layoutMutations.ts`. When
a node has been interacted with, `bringNodeToFront` raises its z-index.
A cloned node at z-index 0 therefore appears behind any previously
interacted node.

The alt-click clone path in `LGraphNode.vue` already handles this
correctly by calling `bringNodeToFront()` after cloning. However, the
menu clone and keyboard paste paths go through `_deserializeItems` in
`LGraphCanvas.ts`, which does not set z-index for new nodes.

| Clone method | Legacy renderer | Vue renderer (before fix) | Vue
renderer (after fix) |
|---|---|---|---|
| Alt-click drag | On top (array order) | On top (`bringNodeToFront`
called) | On top |
| Right-click menu Clone | On top (array order) | Behind original
(z-index 0) | On top |
| Ctrl+C / Ctrl+V | On top (array order) | Behind original (z-index 0) |
On top |

## Steps to Reproduce

1. Enable Node 2.0 mode (Vue renderer) in settings
2. Add any node to the canvas
3. Click or drag the node (raises its z-index via `bringNodeToFront`)
4. Right-click the node and select "Clone"
5. **Expected**: Cloned node appears above the original, immediately
draggable
6. **Actual**: Cloned node appears behind the original; user must move
the original to access the clone

## Changes

After `batchUpdateNodeBounds` in `_deserializeItems`, calls
`bringNodeToFront` for each newly created node so they receive a z-index
above all existing nodes.

## Side Effect Analysis

Checked all call sites of `_deserializeItems`:

1. **Initial graph load / workflow open**: `loadGraphData` in `app.ts`
does NOT call `_deserializeItems`. Workflow loading goes through
`LGraph.configure()` which directly adds nodes and links. The layout
store is initialized separately via `initializeFromLiteGraph`. No side
effect.

2. **Paste from clipboard (Ctrl+V)**: Both `usePaste.ts` (line 52) and
`pasteFromClipboard` (line 4080) call `_deserializeItems`. Pasted nodes
appearing on top is the correct and desired behavior. No issue.

3. **Undo/Redo**: `ChangeTracker.updateState()` calls
`app.loadGraphData()`, which does a full graph reconfigure -- it does
NOT go through `_deserializeItems`. No side effect.

4. **Subgraph blueprint addition**: `litegraphService.ts` (line 906)
calls `_deserializeItems` when adding subgraph blueprints from the node
library. These are freshly placed nodes that should appear on top.
Desired behavior.

5. **Alt-click clone in LGraphNode.vue**: This path calls
`LGraphCanvas.cloneNodes()` -> `_deserializeItems()`, then separately
calls `bringNodeToFront()` again on line 433 of `LGraphNode.vue`. The
second call is now redundant (the node is already at max z-index), but
harmless -- `bringNodeToFront` finds the current max, adds 1, and sets.
The z-index will increment from N to N+1 on the second call. This is a
minor redundancy, not a bug.

6. **Performance**: `bringNodeToFront` iterates all nodes in the layout
store once per call (O(m)) to find max z-index. For n new nodes, the
total cost is O(n*m). In practice, clone/paste operations involve a
small number of nodes (typically 1-10), so this is negligible. For
extremely large pastes (100+ nodes), each call also increments the max
by 1, so z-indices will be sequential (which is actually a reasonable
stacking order).

7. **layoutStore availability**: `layoutStore` is a module-level
singleton (`new LayoutStoreImpl()`) -- not a Pinia store -- so it is
always available. The `useLayoutMutations()` composable is a plain
function returning an object of closures over `layoutStore`. It does not
require Vue component context. No risk of runtime errors.

8. **Legacy renderer (non-Vue mode)**: When Node 2.0 mode is disabled,
the layout store still exists but is not used for rendering. Calling
`bringNodeToFront` will update z-index values in the Yjs document that
are never read. This is harmless.

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `6894b99` `test:` | RED | Test asserts cloned node z-index > original.
Fails with `expected 0 to be greater than 5`. |
| `3567469` `fix:` | GREEN | Calls `bringNodeToFront` for each new node
in `_deserializeItems`. Test passes. |

Fixes #10307

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 12:12:37 +00:00
Alexander Brown
3043b181d7 refactor: extract composables from VTU holdout components, complete VTL migration (#10966)
## Summary

Extract internal logic from the 2 remaining VTU holdout components into
composables, enabling full VTL migration.

## Changes

- **What**: Extract `useProcessedWidgets` from `NodeWidgets.vue`
(486→135 LOC) and `useWidgetSelectItems`/`useWidgetSelectActions` from
`WidgetSelectDropdown.vue` (563→170 LOC). Rewrite both component test
files as composable unit tests + slim behavioral VTL tests. Remove
`@vue/test-utils` devDependency.
- **Dependencies**: Removes `@vue/test-utils`

## Review Focus

- Composable extraction is mechanical — no logic changes, just moving
code into testable units
- `useProcessedWidgets` handles widget deduplication, promotion border
styling, error detection, and identity resolution (~290 LOC)
- `useWidgetSelectItems` handles the full computed chain from widget
values → dropdown items including cloud asset mode and multi-output job
resolution (~350 LOC)
- `useWidgetSelectActions` handles selection resolution and file upload
(~120 LOC)
- 40 new composable-level unit tests replace 13 `wrapper.vm.*` accesses
across the 2 holdout files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10966-refactor-extract-composables-from-VTU-holdout-components-complete-VTL-migration-33c6d73d36508148a3a4ccf346722d6d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 19:04:16 -07:00
Alexander Brown
8c9328c1b2 feat: add eslint-plugin-playwright via oxlint JS plugins (#11136)
## Summary

Add eslint-plugin-playwright as an oxlint JS plugin scoped to
browser_tests/, enforcing Playwright best practices at lint time.

## Changes

- **What**: Configure eslint-plugin-playwright@2.10.1 via oxlint's alpha
`jsPlugins` field (`.oxlintrc.json` override scoped to
`browser_tests/**/*.ts`). 18 recommended rules +
`prefer-native-locators` + `require-to-pass-timeout` at error severity.
All 173 initial violations resolved (config, auto-fix, manual fixes).
`no-force-option` set to off — 28 violations need triage (canvas overlay
workarounds vs unnecessary force) in a dedicated PR.
- **Dependencies**: `eslint-plugin-playwright@^2.10.1` (devDependency,
required by oxlint jsPlugins at runtime)

## Review Focus

- `.oxlintrc.json` override structure — this is the first use of
oxlint's JS plugins alpha feature in this repo
- Manual fixes in spec files: `waitForSelector` → `locator.waitFor`,
deprecated page methods → locator equivalents, `toPass()` timeout
additions
- Compound CSS selectors replaced with `.and()` (Playwright native
locator composition) to avoid `prefer-native-locators` suppressions
- Lint script changes in `package.json` to include `browser_tests/` in
oxlint targets

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-11 01:25:14 +00:00
Christian Byrne
577f373cde fix: auto fit-to-view on first subgraph entry (#10995)
## Summary

Auto-fit viewport to subgraph content on first entry so interior nodes
are immediately visible.

## Changes

- **What**: On cache miss in `restoreViewport()`, call `fitView()` via
`requestAnimationFrame` instead of silently returning. Existing
cache-hit path (revisiting a subgraph) is unchanged.

## Review Focus

The `anyItemOverlapsRect` guard in `app.ts` (workflow load path) is
intentionally **not** touched — it serves a different purpose
(respecting `extra.ds` on workflow load). This fix only affects subgraph
navigation transitions where there is no saved viewport to respect.

Fixes #8173

## Screenshots (if applicable)

N/A — viewport positioning fix; before: empty canvas on subgraph entry,
after: nodes visible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10995-fix-auto-fit-to-view-on-first-subgraph-entry-33d6d73d365081f3a9b3cc2124979624)
by [Unito](https://www.unito.io)
2026-04-11 00:45:38 +00:00
Christian Byrne
44f88027b6 fix: debounce reconnecting toast to prevent false-positive banner (#10997)
## Summary

Debounce the reconnecting toast with a 1.5s grace period so brief
WebSocket blips don't flash a persistent error banner.

## Problem

After #9543 made all error toasts sticky (no auto-dismiss), brief
WebSocket disconnections (<1s) would show a persistent "Reconnecting..."
error banner that stays until the `reconnected` event fires. On staging,
users see this banner even though jobs are actively executing.

## Changes

- Extract reconnection toast logic from `GraphView.vue` into
`useReconnectingNotification` composable
- Add 1.5s delay (via `useTimeoutFn` from VueUse) before showing the
reconnecting toast
- If `reconnected` fires within the delay window, suppress both the
error and success toasts entirely
- Clean up unused `useToast`/`useI18n` imports from `GraphView.vue`

## Testing

- Sub-1.5s disconnections: no toast shown
- Longer disconnections (>1.5s): error toast shown, cleared with success
toast on reconnect
- Setting `Comfy.Toast.DisableReconnectingToast`: no toasts shown at all
- Multiple rapid `reconnecting` events: only one toast shown

6 unit tests covering all scenarios.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10997-fix-debounce-reconnecting-toast-to-prevent-false-positive-banner-33d6d73d3650810289e8f57c046972f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 00:43:42 +00:00
Comfy Org PR Bot
5d07de1913 1.44.2 (#11151)
Patch version increment to 1.44.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11151-1-44-2-33f6d73d3650815c9767fa5668e67a47)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-11 00:41:25 +00:00
Johnpaul Chiwetelu
f0ae91de43 test: add e2e coverage for alt+drag duplicate and meta multi-select drag (#10994)
## Summary

Add Playwright coverage for two previously-untested canvas gestures:
Alt+drag to duplicate a regular node, and holding Meta across
click-click-click-drag to move multiple selected Vue nodes together.

## Changes

- **What**:
- `browser_tests/tests/interaction.spec.ts` — new `Node Duplication`
sub-describe with `Can duplicate a regular node via Alt+drag`. Asserts
CLIPTextEncode count goes 2 → 3 via poll, original node still exists.
Exercises the legacy canvas path at
`src/lib/litegraph/src/LGraphCanvas.ts:2434-2458`, which was only tested
for subgraph nodes before.
- `browser_tests/tests/vueNodes/interactions/node/move.spec.ts` — new
`should move all selected nodes together when dragging one with Meta
held`. Holds Meta for the entire sequence (3× `click({ modifiers:
['Meta'] })` + drag), asserts selection stays at 3 and all three nodes
move by the same delta. Exercises
`src/renderer/extensions/vueNodes/layout/useNodeDrag.ts:54-191`.
- Small refactor: `getLoadCheckpointHeaderPos` now delegates to a
reusable `getHeaderPos(comfyPage, title)` helper. Added `deltaBetween`
and `expectSameDelta` utilities (stricter than `expectPosChanged`, which
only checks `> 0`).


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10994-test-add-e2e-coverage-for-alt-drag-duplicate-and-meta-multi-select-drag-33d6d73d3650812dbf15c7053f44f0fd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 00:18:16 +00:00
Alexander Brown
fb8025c49f chore: disable vitest/require-mock-type-parameters oxlint rule (#11146)
## Summary

Disables the `vitest/require-mock-type-parameters` oxlint rule,
eliminating all 2,813 lint warnings in the codebase.

## Details

Every warning came from this single rule requiring explicit type
parameters on `vi.fn()` calls. Investigation showed:

- **85% are bare `vi.fn()`** — no type info available without manual
cross-referencing
- The rule's auto-fixer is **declared but not implemented** — `lint:fix`
doesn't help
- No existing codemods exist for this
- A full manual sweep would take 3–5 days across ~210 test files

## Test Plan

- `pnpm lint` passes with 0 warnings, 0 errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11146-fix-disable-vitest-require-mock-type-parameters-oxlint-rule-33e6d73d36508186bf1cdc2ce6d2ba57)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-11 00:18:09 +00:00
Alexander Brown
5c14badc42 fix(vite): hide git rev-parse window on Windows (#11144)
## Summary

Add `windowsHide: true` to the `execSync('git rev-parse HEAD')` call in
`vite.config.mts` to prevent a console window from flashing on Windows
during builds.

## Changes

- **What**: Pass `windowsHide: true` option to `execSync` when fetching
the git commit hash at build time. This suppresses the transient cmd.exe
popup that appears on Windows.

## Review Focus

Minimal, single-option change. `windowsHide` is a Node.js built-in
option for `child_process` methods — no-op on non-Windows platforms.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11144-fix-vite-hide-git-rev-parse-window-on-Windows-33e6d73d365081ed9a14da5f47ccac4d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 23:42:26 +00:00
pythongosssss
82fb8ce658 test: App mode - Execution tests (#10801)
## Summary

Adds tests that simulate the execution flow and output feed

## Changes

- **What**: 
- Add ExecutionHelper for mocking network activity
- Refactor ws fixture to use Playwright websocket helper instead of
patching window
- 

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10801-test-App-mode-Execution-tests-3356d73d365081e4acf0c34378600031)
by [Unito](https://www.unito.io)
2026-04-10 23:31:56 +00:00
Christian Byrne
c3e823e55b fix: use standard size-4 for blueprint action icons (#10992)
## Summary

Fix undersized delete and edit icons on user blueprint items in the node
library sidebar.

## Changes

- **What**: Changed blueprint action icons (trash, edit) from `size-3.5`
(14px) to `size-4` (16px), matching the standard icon size used across
the codebase.

## Review Focus

Trivial sizing fix — `size-4` is the codebase-wide convention for
iconify icons in buttons, and what the button base styles default SVGs
to.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10992-fix-use-standard-size-4-for-blueprint-action-icons-33d6d73d365081be8c65f9e2a7b1d6ec)
by [Unito](https://www.unito.io)
2026-04-10 23:04:17 +00:00
pythongosssss
ebc9025de5 fix/feat: App mode - Persist user resized widget heights (#10993)
## Summary

Saves the user sized textarea/image dropzone elements to the linearData
in the workflow.

## Changes

- **What**: 
- Adds a 3rd element to the linearData input tuple for configuration
data
- Add appmode widget resize composable for persisting resizes
- Tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10993-fix-feat-App-mode-Persist-user-resized-widget-heights-33d6d73d36508144b700c6bfcbfa5b3c)
by [Unito](https://www.unito.io)
2026-04-10 22:24:46 +00:00
Alexander Brown
0353524e6f refactor: standardize Page Object locators as public readonly instead of getters (#11135)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Convert ~120 getter-based locators across 18 browser test fixture
files to `public readonly` constructor-assigned properties
- Removes unnecessary indirection, makes object shape explicit, and
improves IDE auto-complete / type inference
- Keeps lazy-init getters (`??=`), computed properties, and `private get
page()` convenience accessors as getters

## Changes

**`browser_tests/fixtures/components/`** (6 files):
`ComfyNodeSearchBox`, `ContextMenu`, `SettingDialog`, `SignInDialog`,
`SidebarTab` (all 6 classes), `Topbar`

**`browser_tests/fixtures/`** (4 files): `ComfyPage` (ComfyMenu.buttons,
ComfyPage.visibleToasts), `UserSelectPage`, `ComfyMouse`,
`VueNodeHelpers`

**`browser_tests/fixtures/helpers/`** (7 files): `AppModeHelper`,
`BuilderFooterHelper`, `BuilderSaveAsHelper`, `BuilderSelectHelper`,
`BuilderStepsHelper`, `ToastHelper`, `NodeOperationsHelper`

**`browser_tests/fixtures/utils/`** (1 file): `vueNodeFixtures`

## Validation

- `pnpm typecheck` 
- `pnpm typecheck:browser` 
- `pnpm exec eslint browser_tests/fixtures/` 
- All pre-commit hooks pass (oxfmt, oxlint, eslint, typecheck,
typecheck:browser) 
- No visual/manual verification needed — changes are test fixture
locator declarations only (no UI or runtime behavior change)

Fixes #11131

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11135-refactor-standardize-Page-Object-locators-as-public-readonly-instead-of-getters-33e6d73d3650819690cbc639f3d30daf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-10 21:10:17 +00:00
Alexander Brown
f1bb756929 fix: remove redundant and counterproductive e2e timeout overrides (#11110)
## Summary

Alright, alright, alright. These e2e tests have been runnin' around like
they're late for somethin', settin' tight little timeouts like the
world's gonna end in 250 milliseconds. Man, you gotta *breathe*. Let the
framework do its thing. Go slow to go fast, that's what I always say.

## Changes

- **What**: Removed ~120 redundant timeout overrides from auto-retrying
Playwright assertions (`toBeVisible`, `toBeHidden`, `toHaveCount`,
`toBeEnabled`, `toHaveAttribute`, `toContainText`, `expect.poll`) where
5000ms is already the default. Also removed sub-5s timeouts (1s, 2s, 3s)
that were just *begging* for flaky failures — like wearin' a belt and
suspenders and also holdin' your pants up with both hands. Raised the
absurdly short timeouts in `customMatchers.ts` (250ms `toPass` → 5000ms,
256ms poll → default). Kept `timeout: 5000` on `.toPass()` calls
(defaults to 0), `.waitFor()`, `waitForRequest`, `waitForFunction`,
intentionally-short timeouts inside retry loops, and conditional
`.isVisible()/.catch()` checks — those fellas actually need the help.

## Review Focus

Every remaining timeout in the diff is there for a *reason*. The ones on
`.toPass()` stay because that API defaults to zero — it won't retry at
all without one. The ones on `.waitFor()` and `waitForRequest` stay
because those are locator actions, not auto-retrying assertions. The
intentionally-short ones inside `toPass` retry loops
(`interaction.spec.ts`) and the negative assertions (`actionbar.spec.ts`
confirming no response arrives) — those are *supposed* to be tight.

The short timeouts on regular assertions were actively *encouragin'*
flaky failures. That's like settin' your alarm for 4 AM and then gettin'
mad you're tired. Just... don't do that, man. Let things take the time
they need.

38 files, net -115 lines. Less code, more chill. That's livin'.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 19:47:20 +00:00
Christian Byrne
d70c3cbfc2 fix: remove ticket-intake skill from repo (#11127)
Removes the `.claude/skills/ticket-intake/` directory — personal
pipeline automation that doesn't belong in the shared repository.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11127-fix-remove-ticket-intake-skill-from-repo-33e6d73d365081849f00d7f988ac17e5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-10 11:42:47 -07:00
pythongosssss
e38dd1efae test: App mode - Add test for cross-workflow input corruption (#10944)
## Summary

Adds test for the issue where inputs from one workflow would overwrite
those on another after sorting

## Changes

- **What**: 
- add test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10944-test-App-mode-Add-test-for-cross-workflow-input-corruption-33b6d73d365081deab26d8f23d2318ae)
by [Unito](https://www.unito.io)
2026-04-10 17:55:33 +00:00
Comfy Org PR Bot
62779d3c51 1.44.1 (#11007)
Patch version increment to 1.44.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11007-1-44-1-33e6d73d365081e3bbd3c02a3a7ca5d0)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-10 17:35:22 +00:00
Christian Byrne
4cf160d66e fix: disable pointer events on non-visible DOM widget overlays (#11063)
## Problem

When a node with DOM widget overlays (e.g. CLIPTextEncode) is collapsed,
the overlay elements can intercept pointer events intended for the
canvas collapse toggler, making click-to-expand unreliable.

## Root Cause

`updateWidgets()` runs during `onDrawForeground` (canvas render cycle)
and sets `widgetState.visible = false` for collapsed nodes. `v-show`
then hides the element with `display: none`. However, there is a timing
gap between the canvas state change and Vue's DOM update — during this
gap the widget overlay still intercepts pointer events.

## Fix

Add `!widgetState.visible` to the `pointerEvents` condition in
`composeStyle()`. This immediately sets `pointer-events: none` when the
widget becomes invisible, preventing event interception before `v-show`
applies `display: none`.

Also restores click-to-expand in the E2E test, removing the programmatic
`node.collapse()` workaround from PR #10967.

- Fixes #11006

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11063-fix-disable-pointer-events-on-non-visible-DOM-widget-overlays-33e6d73d36508179a83cd47121cf933f)
by [Unito](https://www.unito.io)
2026-04-10 14:28:25 +00:00
pythongosssss
c0871ba219 ci: Update actions for queue merge validation (#11114)
## Summary

PR merge queue was enabled but actions were not updated to trigger on
`merge_group`:

https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue#triggering-merge-group-checks-with-github-actions

## Changes

- **What**: 
- `.github/actions/lint-format-verify/action.yml` extrated shared Verify
lint and format steps
- `.github/workflows/ci-lint-format.yaml` updated to use shared
composite action
- `.github/workflows/ci-lint-format-queue.yaml` new action that triggers
on merge_group to validate format
- `.github/workflows/ci-tests-e2e.yaml` triggers on merge_group
- `.github/workflows/ci-tests-unit.yaml` triggers on merge_group

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11114-ci-Update-actions-for-queue-merge-validation-33e6d73d3650815f8c88f40736b513ec)
by [Unito](https://www.unito.io)
2026-04-10 17:09:48 +00:00
Terry Jia
6d4fc1bb10 fix: resolve incorrect GLSL live preview for non-primitive widget types (#11010)
## Summary

Three issues caused GLSL preview to diverge from backend results:

1. Uniform source resolution always read widgets[0] instead of using
link.origin_slot to select the correct widget. Added directValue
fallback for widgets not registered in widgetValueStore.

2. Hex color strings (e.g. "#45edf5") were coerced to 0 by Number().
Added hexToInt to colorUtil and used it in toNumber coercion.

3. Custom size_mode was ignored — preview always used upstream image
dimensions. Now checks size_mode widget first and respects "custom".

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11010-fix-resolve-incorrect-GLSL-live-preview-for-non-primitive-widget-types-33e6d73d36508101a76bfe8383c0c6ab)
by [Unito](https://www.unito.io)
2026-04-10 10:21:27 -04:00
Alexander Brown
0132c77c7d test: harden 82 Playwright specs for deterministic CI runs (#10967)
## Summary

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

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

## Changes

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

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

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

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

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

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

## Review Focus

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

---------

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

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

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

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

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

---------

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

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

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

- Fixes #10824

## Red-Green Verification

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

## Test Plan

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

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

Part of the FixIt Burndown test coverage initiative.

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

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

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

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

### Root cause

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

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

### Fix

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

### QA evidence

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

### Related

- Fixes the remaining issue from #10220

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

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

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

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

---------

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

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

## Changes

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

## Review Focus

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

Ticket: COM-17992

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10890-feat-integrate-Typeform-survey-into-feedback-button-33a6d73d36508185abbfe57e7a36b5f7)
by [Unito](https://www.unito.io)
2026-04-09 14:40:52 -07:00
pythongosssss
833a2f5161 fix expand/collapse 2026-04-07 07:33:59 -07:00
pythongosssss
4f97a9028c update buttons 2026-04-07 06:16:40 -07:00
pythongosssss
3786a46ef7 Merge branch 'main' into pysssss/node-search-feedback 2026-04-07 10:58:00 +01:00
pythongosssss
5c3de0081b fix pr comments 2026-04-07 02:56:27 -07:00
pythongosssss
af77920f8f Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-27 04:27:46 -07:00
pythongosssss
c00e285768 fix tests 2026-03-25 08:40:27 -07:00
pythongosssss
8f41bc7527 update font size 2026-03-24 08:47:31 -07:00
pythongosssss
11b62c48e3 fix 2026-03-24 08:37:38 -07:00
pythongosssss
cc3d3f1d25 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-24 08:31:57 -07:00
pythongosssss
92e65aaaa7 remove dead code 2026-03-23 07:09:18 -07:00
pythongosssss
f82f8624e1 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-20 14:53:55 -07:00
pythongosssss
c46316d248 feedback 2026-03-20 14:51:33 -07:00
pythongosssss
8e5dc15e5d Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-19 12:45:01 -07:00
pythongosssss
da2fedebcf fix incorrectly collapsing parent category to root 2026-03-18 09:19:47 -07:00
pythongosssss
2a531ff80b fix test 2026-03-18 06:58:13 -07:00
pythongosssss
b6234b96af rework expand/collapse, prevent requiring double left arrow to collapse 2026-03-18 06:55:29 -07:00
pythongosssss
bd66617d3f Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-18 04:27:25 -07:00
pythongosssss
98eac41f07 fix highlighting cross word and remove padding 2026-03-17 08:00:00 -07:00
pythongosssss
307a1c77c0 ensure canvas gets focus after ghost placement 2026-03-17 07:40:28 -07:00
pythongosssss
bbd1e60f7b cap description size 2026-03-17 07:19:47 -07:00
pythongosssss
9100058fc1 fix dialog height 2026-03-17 06:53:47 -07:00
pythongosssss
04c00aadd8 remove left categories and add as filter buttons 2026-03-16 09:05:05 -07:00
pythongosssss
2f1615c505 fix test 2026-03-16 08:36:53 -07:00
pythongosssss
cf4dfceaee - fix dialog stealing focus
- fix tab vs click chevron focus visibility
2026-03-16 08:21:46 -07:00
pythongosssss
dbb70323bf fix bad merge 2026-03-16 08:07:41 -07:00
pythongosssss
6689510591 fix 2026-03-16 08:06:36 -07:00
pythongosssss
82e62694a9 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-16 07:53:48 -07:00
pythongosssss
d49f263536 add e2e test for moving ghost 2026-03-16 07:47:00 -07:00
pythongosssss
d30bb01b4b fix: prevent other nodes intercepting ghost node capture 2026-03-16 06:50:11 -07:00
pythongosssss
320cd82f0d rename custom to extensions 2026-03-16 06:12:38 -07:00
pythongosssss
8a30211bea - dont clear category + allow category searching
- loop focus in dialog
2026-03-16 06:10:26 -07:00
pythongosssss
12fd0981a8 hide extensions/custom categories when no custom nodes 2026-03-12 13:51:35 -07:00
pythongosssss
0772f2a7fe - make test more specific
- fix expanding after manual categ collapse
2026-03-12 13:19:07 -07:00
pythongosssss
08666d8e81 rabbit
- update plural item selected entry
- update mock bookmarts to default empty
- fix test testing already sorted data
- prevent autoExpand already expanded
- fix aria role
- add test + fix path matching
2026-03-12 05:05:42 -07:00
pythongosssss
d18243e085 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-12 04:39:45 -07:00
pythongosssss
3cba424e52 additional search feedback
- improved keyboard navigation and aria
- fixed alignment of elements
- updated fonts and sizes
- more tidy + nits
- tests
2026-03-12 04:18:20 -07:00
pythongosssss
0f3b2e0455 fix test 2026-03-10 08:19:42 -07:00
pythongosssss
fd31f9d0ed additional node search updates
- add root filter buttons
- replace input/output selection with popover
- replace price badge with one from node header
- fix bug with hovering selecting item under mouse automatically
- fix tailwind merge with custom sizes removing them
- general tidy/refactor/test
2026-03-10 07:36:40 -07:00
344 changed files with 16036 additions and 7282 deletions

View File

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

View File

@@ -1,361 +0,0 @@
---
name: ticket-intake
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
---
# Ticket Intake
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
## Supported Sources
| Source | URL Pattern | Provider File |
| ------ | --------------------------------------------------- | --------------------- |
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
## Quick Start
When given a ticket URL:
1. **Detect source type** from URL pattern
2. **Load provider-specific logic** from `providers/` directory
3. Fetch ticket content via appropriate API
4. Extract and normalize properties to common schema
5. **Register ticket in pipeline API** ← MANDATORY
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
7. **Run verification script** to confirm API registration
8. Output summary and handoff to `research-orchestrator`
## Configuration
Uses the **production API** by default. No configuration needed for read operations.
**Defaults (no setup required):**
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
- Read-only endpoints at `/public/*` require no authentication
**For write operations** (transitions, creating tickets), set:
```bash
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
```
**Optional (for local working artifacts):**
```bash
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
```
## Mandatory API Calls (Execute ALL Three)
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
### Call 1: Create Ticket
```bash
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
API_KEY="${PIPELINE_API_KEY}"
curl -s -X POST "${API_URL}/v1/tickets" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"notion_page_id": "NOTION_PAGE_UUID_HERE",
"title": "TICKET_TITLE_HERE",
"source": "notion",
"metadata": {
"description": "DESCRIPTION_HERE",
"priority": "High",
"labels": [],
"acceptanceCriteria": []
}
}'
```
Save the returned `id` — you need it for the next two calls.
### Call 2: Transition to RESEARCH
```bash
TICKET_ID="id-from-step-1"
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"to_state": "RESEARCH",
"reason": "Intake complete, starting research"
}'
```
### Call 3: Queue Source Update
```bash
curl -s -X POST "${API_URL}/v1/sync/queue" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"ticket_id": "TICKET_ID_HERE",
"action": "update_status",
"payload": { "status": "In Progress" },
"priority": "normal"
}'
```
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
### TypeScript Equivalent (if using pipeline client)
```typescript
import { PipelineClient } from '@pipeline/client'
const client = new PipelineClient({
apiUrl:
process.env.PIPELINE_API_URL ||
'https://api-gateway-856475788601.us-central1.run.app',
agentId: process.env.AGENT_ID!
})
const ticket = await client.createTicket({
notion_page_id: pageId,
title: ticketTitle,
source: 'notion',
metadata: { description, priority, labels, acceptanceCriteria }
})
await client.transitionState(
ticket.id,
'RESEARCH',
'Intake complete, starting research'
)
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
```
## Workflow
### Step 1: Detect Source Type
Parse the URL to determine source:
```javascript
if (url.includes('notion.so')) {
source = 'notion'
// Load providers/notion.md
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
source = 'github'
// Load providers/github.md
} else {
// Error: Unsupported URL format
}
```
### Step 2: Load Provider and Fetch Data
Read the appropriate provider file for source-specific instructions:
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
Follow the provider's instructions for:
- Fetching content
- Extracting properties
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
### Step 3: Normalize to Common Schema
All providers must extract normalized ticket data following `schema.md`:
```json
{
"id": "abc12345",
"url": "https://...",
"source": "notion | github",
"title": "Ticket title",
"description": "Full description",
"status": "Not Started",
"assignee": "username",
"priority": "High",
"area": "UI",
"labels": ["bug", "frontend"],
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
"fetchedAt": "2024-01-15T10:30:00Z"
}
```
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
1. `createTicket()` → save the returned ticket ID
2. `transitionState(id, 'RESEARCH')` → confirm state changed
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
### Step 5: Run Verification Script
After making the API calls, run the verification script to confirm everything worked:
```bash
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
```
**If the script is not available locally**, verify manually via the public API:
```bash
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
```
Expected output:
```json
{
"id": "...",
"state": "RESEARCH",
"title": "...",
"notion_page_id": "..."
}
```
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
### Step 6: Output Summary and Handoff
Print a clear summary:
```markdown
## Ticket Intake Complete
**Source:** Notion | GitHub
**Title:** [Ticket title]
**ID:** abc12345
**Status:** In Progress (queued)
**Priority:** High
**Area:** UI
### Description
[Brief description or first 200 chars]
### Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
### Links
- **Ticket:** [Original URL]
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
### Pipeline
- **API Ticket ID:** abc12345
- **State:** RESEARCH
- **Verified:** ✅ (via verify-intake.sh or public API)
```
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
## Error Handling
### Unsupported URL
```
❌ Unsupported ticket URL format.
Supported formats:
- Notion: https://notion.so/... or https://www.notion.so/...
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
Received: [provided URL]
```
### Provider-Specific Errors
See individual provider files for source-specific error handling:
- `providers/notion.md` - Authentication, page not found
- `providers/github.md` - Auth, rate limits, issue not found
### Missing Properties
Continue with available data and note what's missing:
```
⚠️ Some properties unavailable:
- Priority: not found (using default: Medium)
- Area: not found
Proceeding with available data...
```
### API Call Failures
```
❌ Pipeline API call failed: {method} {endpoint}
Status: {status}
Error: {message}
Retrying once...
❌ Retry also failed. INTAKE IS INCOMPLETE.
The ticket was NOT registered in the pipeline.
Downstream skills will not work until this is fixed.
```
## Notes
- This skill focuses ONLY on intake — it does not do research
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
- Pipeline state is tracked via the API, not local files
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
- The `source` field in the ticket determines which research strategies to use
## API Client Reference
### Available Methods
| Method | Description |
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
| `getTicket(id)` | Retrieve a ticket by ID |
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
### Error Handling
```typescript
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
try {
await client.createTicket({ ... });
} catch (error) {
if (error instanceof PipelineAPIError) {
console.error(`API Error ${error.status}: ${error.message}`);
}
throw error;
}
```

View File

@@ -1,194 +0,0 @@
# GitHub Provider - Ticket Intake
Provider-specific logic for ingesting tickets from GitHub Issues.
## URL Pattern
```
https://github.com/{owner}/{repo}/issues/{number}
https://www.github.com/{owner}/{repo}/issues/{number}
```
Extract: `owner`, `repo`, `issue_number` from URL.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Access to the repository
## Fetch Issue Content
Use `gh` CLI to fetch issue details:
```bash
# Get issue details in JSON
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
# Get comments separately if needed
gh issue view {number} --repo {owner}/{repo} --comments
```
## Extract Ticket Data
Map GitHub issue fields to normalized ticket data (stored via API):
| GitHub Field | ticket.json Field | Notes |
| ------------ | ----------------- | -------------------------- |
| title | title | Direct mapping |
| body | description | Issue body/description |
| state | status | Map: open → "Not Started" |
| labels | labels | Array of label names |
| assignees | assignee | First assignee login |
| author | author | Issue author login |
| milestone | milestone | Milestone title if present |
| comments | comments | Array of comment objects |
| linkedPRs | linkedPRs | PRs linked to this issue |
### Priority Mapping
Infer priority from labels:
- `priority:critical`, `P0` → "Critical"
- `priority:high`, `P1` → "High"
- `priority:medium`, `P2` → "Medium"
- `priority:low`, `P3` → "Low"
- No priority label → "Medium" (default)
### Area Mapping
Infer area from labels:
- `area:ui`, `frontend`, `component:*` → "UI"
- `area:api`, `backend` → "API"
- `area:docs`, `documentation` → "Docs"
- `bug`, `fix` → "Bug"
- `enhancement`, `feature` → "Feature"
## Update Source
**For GitHub issues, update is optional but recommended.**
Add a comment to indicate work has started:
```bash
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
```
Optionally assign to self:
```bash
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
```
Log any updates via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
githubWrites: [
...(ticket.metadata?.githubWrites || []),
{
action: 'comment',
issueNumber: 123,
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
## GitHub-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "github",
"githubOwner": "Comfy-Org",
"githubRepo": "ComfyUI_frontend",
"githubIssueNumber": 123,
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
"labels": ["bug", "area:ui", "priority:high"],
"linkedPRs": [456, 789],
"dosuComment": "..." // Extracted Dosu bot analysis if present
}
```
## Dosu Bot Detection
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
```bash
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
```
Look for comments from:
- `dosu[bot]`
- `dosu-bot`
Extract Dosu analysis which typically includes:
- Root cause analysis
- Suggested files to modify
- Related issues/PRs
- Potential solutions
Store in ticket data via API:
```json
{
"dosuComment": {
"found": true,
"analysis": "...",
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
"relatedIssues": [100, 101]
}
}
```
## Extract Linked Issues/PRs
Parse issue body and comments for references:
- `#123` → Issue or PR reference
- `fixes #123`, `closes #123` → Linked issue
- `https://github.com/.../issues/123` → Full URL reference
Store in ticket data via API for research phase:
```json
{
"referencedIssues": [100, 101, 102],
"referencedPRs": [200, 201]
}
```
## Error Handling
### Authentication Error
```
⚠️ GitHub CLI not authenticated.
Run: gh auth login
```
### Issue Not Found
```
❌ GitHub issue not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this repository
- Run: gh auth status
```
### Rate Limiting
```
⚠️ GitHub API rate limited.
Wait a few minutes and try again.
Check status: gh api rate_limit
```

View File

@@ -1,202 +0,0 @@
# Notion Provider - Ticket Intake
Provider-specific logic for ingesting tickets from Notion.
## URL Pattern
```
https://www.notion.so/workspace/Page-Title-abc123def456...
https://notion.so/Page-Title-abc123def456...
https://www.notion.so/abc123def456...
```
Page ID is the 32-character hex string (with or without hyphens).
## Prerequisites
- Notion MCP connected and authenticated
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
- Authenticate via `/mcp` command if prompted
## Fetch Ticket Content
Use `Notion:notion-fetch` with the page URL or ID:
```
Fetch the full page content including all properties
```
## Extract Ticket Data
Extract these properties (names may vary):
| Property | Expected Name | Type |
| ------------- | ------------------------- | ------------ |
| Title | Name / Title | Title |
| Status | Status | Select |
| Assignee | Assignee / Assigned To | Person |
| Description | - | Page content |
| Slack Link | Slack Link / Slack Thread | URL |
| GitHub PR | GitHub PR / PR Link | URL |
| Priority | Priority | Select |
| Area | Area / Category | Select |
| Related Tasks | Related Tasks | Relation |
**If properties are missing**: Note what's unavailable and continue with available data.
## Update Source (REQUIRED)
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
Use `Notion:notion-update-page` to update the ticket:
1. **Status**: Set to "In Progress" (only valid from "Not Started")
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
```json
{
"page_id": "{page_id_from_ticket}",
"command": "update_properties",
"properties": {
"Status": "In Progress",
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
}
}
```
**After the update succeeds**, log the write via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
notionWrites: [
...(ticket.metadata?.notionWrites || []),
{
field: 'Status',
value: 'In Progress',
previousValue: 'Not Started',
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
If update fails, log with `success: false` and continue.
## Notion-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "notion",
"notionPageId": "abc123def456...",
"slackLink": "https://slack.com/...",
"relatedTasks": ["page-id-1", "page-id-2"]
}
```
## Slack Thread Handling
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
### Slack URL Conversion
Notion stores Slack links in `slackMessage://` format:
```
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
```
Convert to browser-clickable format:
```
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
```
**Example:**
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
(Remove the dot from the last timestamp and prefix with `p`)
### Fetching Thread Content
Load the `slackdump` skill and use the **export-thread** workflow:
```bash
# Export thread by URL
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
# Or by colon notation (channel_id:thread_ts)
slackdump dump CHANNEL_ID:THREAD_TS
```
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
## Database Reference: Comfy Tasks
The "Comfy Tasks" database has these properties (verify via `notion-search`):
- **Status values**: Not Started, In Progress, In Review, Done
- **Team assignment**: "Frontend Team" for unassigned tickets
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
### Pipeline Owner Details
When assigning tickets, use these identifiers:
| Platform | Identifier |
| --------------- | -------------------------------------- |
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
| Notion Name | Christian Byrne |
| Notion Email | cbyrne@comfy.org |
| Slack User ID | U087MJCDHHC |
| GitHub Username | christian-byrne |
**To update Assignee**, use the Notion User ID (not name):
```
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
```
### Finding Active Tickets
To list your active tickets:
```
Use Notion:notion-search for "Comfy Tasks"
Filter by Assignee = current user OR Team = "Frontend Team"
```
## Error Handling
### Authentication Error
```
⚠️ Notion authentication required.
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
Then authenticate via /mcp command.
```
### Page Not Found
```
❌ Notion page not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this page
- Try re-authenticating via /mcp
```

View File

@@ -1,81 +0,0 @@
# Ticket Schema
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
## Ticket Data Schema
```json
{
// Required fields (all sources)
"id": "string", // Unique identifier (short form)
"url": "string", // Original URL
"source": "notion | github", // Source type
"title": "string", // Ticket title
"description": "string", // Full description/body
"fetchedAt": "ISO8601", // When ticket was fetched
// Common optional fields
"status": "string", // Current status
"assignee": "string", // Assigned user
"priority": "string", // Priority level
"area": "string", // Category/area
"labels": ["string"], // Tags/labels
"acceptanceCriteria": ["string"] // List of AC items
// Source-specific fields (see providers)
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
}
```
## Ticket State Schema (via API)
State is managed via the Pipeline API using `client.transitionState()`:
```json
{
"ticketId": "string",
"state": "intake | research | planning | implementation | pr_created | done | failed",
"stateChangedAt": "ISO8601",
// Timestamps tracked by API
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
```
## Priority Normalization
All sources should normalize to these values:
| Normalized | Description |
| ---------- | ------------------------- |
| Critical | Production down, security |
| High | Blocking work, urgent |
| Medium | Normal priority (default) |
| Low | Nice to have, backlog |
## Status Normalization
Pipeline tracks these statuses internally:
| Status | Description |
| -------------- | ---------------------------- |
| research | Gathering context |
| planning | Creating implementation plan |
| implementation | Writing code |
| review | Code review in progress |
| qa | Quality assurance |
| done | PR merged or completed |
## ID Generation
IDs are generated by the API when creating tickets. For reference:
- **Notion**: First 8 characters of page ID
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
Examples:
- Notion: `abc12345`
- GitHub: `gh-comfy-org-frontend-123`

View File

@@ -0,0 +1,31 @@
name: 'Lint and format verify'
description: >
Runs the lint/format/knip verification suite plus a conditional
browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and
ci-lint-format-queue.yaml (merge queue) so both paths run the exact
same checks. The caller is responsible for checkout and frontend setup
before invoking this action.
runs:
using: composite
steps:
- name: Detect browser_tests changes
id: changed-paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
browser_tests:
- 'browser_tests/**'
- name: Verify lint and format
shell: bash
run: |
pnpm lint
pnpm stylelint
pnpm format:check
pnpm knip
- name: Typecheck browser tests
if: steps.changed-paths.outputs.browser_tests == 'true'
shell: bash
run: pnpm typecheck:browser

View File

@@ -0,0 +1,29 @@
# Description: Lint and format verification for GitHub merge queue runs.
# Paired with ci-lint-format.yaml — workflow name and job name must match
# so branch protection resolves a single required check in both the
# pull_request and merge_group contexts. This file runs verify-only steps
# with a read-only token; auto-fix and PR comments live in the PR workflow.
name: 'CI: Lint Format'
on:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- name: Checkout merge group ref
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Verify lint and format
uses: ./.github/actions/lint-format-verify

View File

@@ -1,4 +1,7 @@
# Description: Linting and code formatting validation for pull requests # Description: Linting and code formatting validation for pull requests.
# Paired with ci-lint-format-queue.yaml - workflow name and job name must
# match so branch protection resolves a single required check in both the
# pull_request and merge_group contexts.
name: 'CI: Lint Format' name: 'CI: Lint Format'
on: on:
@@ -26,14 +29,6 @@ jobs:
- name: Setup frontend - name: Setup frontend
uses: ./.github/actions/setup-frontend uses: ./.github/actions/setup-frontend
- name: Detect browser_tests changes
id: changed-paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
browser_tests:
- 'browser_tests/**'
- name: Run ESLint with auto-fix - name: Run ESLint with auto-fix
run: pnpm lint:fix run: pnpm lint:fix
@@ -77,16 +72,8 @@ jobs:
echo "See CONTRIBUTING.md for more details." echo "See CONTRIBUTING.md for more details."
exit 1 exit 1
- name: Final validation - name: Verify lint and format
run: | uses: ./.github/actions/lint-format-verify
pnpm lint
pnpm stylelint
pnpm format:check
pnpm knip
- name: Typecheck browser tests
if: steps.changed-paths.outputs.browser_tests == 'true'
run: pnpm typecheck:browser
- name: Comment on PR about auto-fix - name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository

View File

@@ -8,6 +8,7 @@ on:
pull_request: pull_request:
branches-ignore: [wip/*, draft/*, temp/*] branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md'] paths-ignore: ['**/*.md']
merge_group:
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:

View File

@@ -8,6 +8,7 @@ on:
pull_request: pull_request:
branches-ignore: [wip/*, draft/*, temp/*] branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md'] paths-ignore: ['**/*.md']
merge_group:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -84,6 +84,7 @@
"typescript/no-unsafe-declaration-merging": "off", "typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off", "typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off", "unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"unicorn/no-new-array": "off", "unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off", "unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off", "unicorn/no-useless-fallback-in-spread": "off",
@@ -116,13 +117,60 @@
}, },
{ {
"files": ["browser_tests/**/*.ts"], "files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],
"rules": { "rules": {
"typescript/no-explicit-any": "error", "typescript/no-explicit-any": "error",
"no-async-promise-executor": "error", "no-async-promise-executor": "error",
"no-control-regex": "error", "no-control-regex": "error",
"no-useless-rename": "error", "no-useless-rename": "error",
"no-unused-private-class-members": "error", "no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error" "unicorn/no-empty-file": "error",
"playwright/consistent-spacing-between-blocks": "error",
"playwright/expect-expect": [
"error",
{
"assertFunctionNames": [
"recordMeasurement",
"logMeasurement",
"builderSaveAs"
],
"assertFunctionPatterns": [
"^expect",
"^assert",
"^verify",
"^searchAndExpect",
"waitForOpen",
"waitForClosed",
"waitForRequest"
]
}
],
"playwright/max-nested-describe": "error",
"playwright/no-duplicate-hooks": "error",
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",
"playwright/no-unsafe-references": "error",
"playwright/no-unused-locators": "error",
"playwright/no-useless-await": "error",
"playwright/no-useless-not": "error",
"playwright/no-wait-for-navigation": "error",
"playwright/no-wait-for-selector": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/prefer-hooks-on-top": "error",
"playwright/prefer-locator": "error",
"playwright/prefer-to-have-count": "error",
"playwright/prefer-to-have-length": "error",
"playwright/prefer-web-first-assertions": "error",
"playwright/prefer-native-locators": "error",
"playwright/require-to-pass-timeout": "error",
"playwright/valid-expect": "error",
"playwright/valid-expect-in-promise": "error",
"playwright/valid-title": "error"
} }
} }
] ]

View File

@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead. - Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists - NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc. - Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules ## Agent-only rules

View File

@@ -17,7 +17,7 @@ const features = computed(() => [
<div class="mx-auto max-w-3xl px-6 text-center"> <div class="mx-auto max-w-3xl px-6 text-center">
<!-- Badge --> <!-- Badge -->
<span <span
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow" class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs tracking-widest text-brand-yellow uppercase"
> >
{{ t('academy.badge', locale) }} {{ t('academy.badge', locale) }}
</span> </span>

View File

@@ -40,7 +40,7 @@ const steps = computed(() => [
<!-- Connecting line between steps (desktop only) --> <!-- Connecting line between steps (desktop only) -->
<div <div
v-if="index < steps.length - 1" v-if="index < steps.length - 1"
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block" class="absolute top-8 right-0 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
/> />
<div class="relative"> <div class="relative">

View File

@@ -31,11 +31,11 @@ const ctaButtons = computed(() => [
<div class="flex w-full items-center justify-center md:w-[55%]"> <div class="flex w-full items-center justify-center md:w-[55%]">
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true"> <div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
<div <div
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]" class="size-64 rounded-full border-40 border-brand-yellow md:h-112 md:w-md md:border-64 lg:h-144 lg:w-xl lg:border-80"
> >
<!-- Gap on the right side to form "C" shape --> <!-- Gap on the right side to form "C" shape -->
<div <div
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48" class="absolute top-1/2 right-0 h-32 w-24 translate-x-1/2 -translate-y-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
/> />
</div> </div>
</div> </div>
@@ -44,7 +44,7 @@ const ctaButtons = computed(() => [
<!-- Right: Text content --> <!-- Right: Text content -->
<div class="flex w-full flex-col items-start md:w-[45%]"> <div class="flex w-full flex-col items-start md:w-[45%]">
<h1 <h1
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl" class="text-5xl/tight font-bold tracking-tight text-white md:text-6xl lg:text-7xl"
> >
{{ t('hero.headline', locale) }} {{ t('hero.headline', locale) }}
</h1> </h1>

View File

@@ -17,7 +17,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
{{ t('manifesto.heading', locale) }} {{ t('manifesto.heading', locale) }}
</h2> </h2>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700"> <p class="mx-auto mt-6 max-w-2xl text-lg/relaxed text-smoke-700">
{{ t('manifesto.body', locale) }} {{ t('manifesto.body', locale) }}
</p> </p>

View File

@@ -33,11 +33,11 @@ const features = computed(() => [
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<!-- Play button triangle --> <!-- Play button triangle -->
<div <div
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20" class="flex size-16 items-center justify-center rounded-full border-2 border-white/20"
aria-hidden="true" aria-hidden="true"
> >
<div <div
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white" class="ml-1 size-0 border-y-8 border-l-14 border-y-transparent border-l-white"
/> />
</div> </div>
<p class="text-sm text-smoke-700"> <p class="text-sm text-smoke-700">
@@ -54,7 +54,7 @@ const features = computed(() => [
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<span <span
class="h-2 w-2 rounded-full bg-brand-yellow" class="size-2 rounded-full bg-brand-yellow"
aria-hidden="true" aria-hidden="true"
/> />
<span class="text-sm text-smoke-700">{{ feature }}</span> <span class="text-sm text-smoke-700">{{ feature }}</span>

View File

@@ -32,7 +32,7 @@ const metrics = computed(() => [
<div class="mx-auto max-w-7xl px-6"> <div class="mx-auto max-w-7xl px-6">
<!-- Heading --> <!-- Heading -->
<p <p
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700" class="text-center text-xs font-medium tracking-widest text-smoke-700 uppercase"
> >
{{ t('social.heading', locale) }} {{ t('social.heading', locale) }}
</p> </p>

View File

@@ -90,7 +90,7 @@ const filteredTestimonials = computed(() => {
:key="testimonial.name" :key="testimonial.name"
class="rounded-xl border border-white/10 bg-charcoal-600 p-6" class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
> >
<blockquote class="text-base italic text-white"> <blockquote class="text-base text-white italic">
&ldquo;{{ testimonial.quote }}&rdquo; &ldquo;{{ testimonial.quote }}&rdquo;
</blockquote> </blockquote>

View File

@@ -24,12 +24,12 @@ const activeCategory = ref(0)
<!-- Left placeholder image (desktop only) --> <!-- Left placeholder image (desktop only) -->
<div class="hidden flex-1 lg:block"> <div class="hidden flex-1 lg:block">
<div <div
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600" class="aspect-2/3 rounded-full border border-white/10 bg-charcoal-600"
/> />
</div> </div>
<!-- Center content --> <!-- Center content -->
<div class="flex flex-col items-center text-center lg:flex-[2]"> <div class="flex flex-col items-center text-center lg:flex-2">
<h2 class="text-3xl font-bold text-white"> <h2 class="text-3xl font-bold text-white">
{{ t('useCase.heading', locale) }} {{ t('useCase.heading', locale) }}
</h2> </h2>
@@ -70,7 +70,7 @@ const activeCategory = ref(0)
<!-- Right placeholder image (desktop only) --> <!-- Right placeholder image (desktop only) -->
<div class="hidden flex-1 lg:block"> <div class="hidden flex-1 lg:block">
<div <div
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600" class="aspect-2/3 rounded-3xl border border-white/10 bg-charcoal-600"
/> />
</div> </div>
</div> </div>

View File

@@ -53,7 +53,7 @@ const pillars = computed(() => [
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow" class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
> >
<div <div
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl" class="flex size-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
> >
{{ pillar.icon }} {{ pillar.icon }}
</div> </div>

View File

@@ -40,6 +40,39 @@ browser_tests/
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading). - **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere. - **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
## Page Object Locator Style
Define UI element locators as `public readonly` properties assigned in the constructor — not as getter methods. Getters that simply return a locator add unnecessary indirection and hide the object shape from IDE auto-complete.
```typescript
// ✅ Correct — public readonly, assigned in constructor
export class MyDialog extends BaseDialog {
public readonly submitButton: Locator
public readonly cancelButton: Locator
constructor(page: Page) {
super(page)
this.submitButton = this.root.getByRole('button', { name: 'Submit' })
this.cancelButton = this.root.getByRole('button', { name: 'Cancel' })
}
}
// ❌ Avoid — getter-based locators
export class MyDialog extends BaseDialog {
get submitButton() {
return this.root.getByRole('button', { name: 'Submit' })
}
}
```
**Keep as getters only when:**
- Lazy initialization is needed (`this._tab ??= new Tab(this.page)`)
- The value is computed from runtime state (e.g. `get id() { return this.userIds[index] }`)
- It's a private convenience accessor (e.g. `private get page() { return this.comfyPage.page }`)
When a class has cached locator properties, prefer reusing them in methods rather than rebuilding locators from scratch.
## Polling Assertions ## 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). 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).

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 500],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,100 @@
{
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "ImageCropV2",
"pos": [450, 50],
"size": [400, 500],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2]
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 10,
"y": 10,
"width": 100,
"height": 100
}
]
},
{
"id": 3,
"type": "PreviewImage",
"pos": [900, 50],
"size": [315, 270],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 2, 0, 3, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -26,11 +26,10 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
static defaultSteps = 5 static defaultSteps = 5
static defaultOptions: DragOptions = { steps: ComfyMouse.defaultSteps } static defaultOptions: DragOptions = { steps: ComfyMouse.defaultSteps }
constructor(readonly comfyPage: ComfyPage) {} readonly mouse: Mouse
/** The normal Playwright {@link Mouse} property from {@link ComfyPage.page}. */ constructor(readonly comfyPage: ComfyPage) {
get mouse() { this.mouse = comfyPage.page.mouse
return this.comfyPage.page.mouse
} }
async nextFrame() { async nextFrame() {

View File

@@ -73,15 +73,13 @@ class ComfyMenu {
public readonly sideToolbar: Locator public readonly sideToolbar: Locator
public readonly propertiesPanel: ComfyPropertiesPanel public readonly propertiesPanel: ComfyPropertiesPanel
public readonly modeToggleButton: Locator public readonly modeToggleButton: Locator
public readonly buttons: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
this.sideToolbar = page.getByTestId(TestIds.sidebar.toolbar) this.sideToolbar = page.getByTestId(TestIds.sidebar.toolbar)
this.modeToggleButton = page.getByTestId(TestIds.sidebar.modeToggle) this.modeToggleButton = page.getByTestId(TestIds.sidebar.modeToggle)
this.propertiesPanel = new ComfyPropertiesPanel(page) this.propertiesPanel = new ComfyPropertiesPanel(page)
} this.buttons = this.sideToolbar.locator('.side-bar-button')
get buttons() {
return this.sideToolbar.locator('.side-bar-button')
} }
get modelLibraryTab() { get modelLibraryTab() {
@@ -183,6 +181,7 @@ export class ComfyPage {
public readonly assetApi: AssetHelper public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
/** Worker index to test user ID */ /** Worker index to test user ID */
public readonly userIds: string[] = [] public readonly userIds: string[] = []
@@ -225,6 +224,7 @@ export class ComfyPage {
this.workflow = new WorkflowHelper(this) this.workflow = new WorkflowHelper(this)
this.contextMenu = new ContextMenu(page) this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page) this.toast = new ToastHelper(page)
this.visibleToasts = this.toast.visibleToasts
this.dragDrop = new DragDropHelper(page) this.dragDrop = new DragDropHelper(page)
this.featureFlags = new FeatureFlagHelper(page) this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page) this.command = new CommandHelper(page)
@@ -237,10 +237,6 @@ export class ComfyPage {
this.cloudAuth = new CloudAuthHelper(page) this.cloudAuth = new CloudAuthHelper(page)
} }
get visibleToasts() {
return this.toast.visibleToasts
}
async setupUser(username: string) { async setupUser(username: string) {
const res = await this.request.get(`${this.url}/api/users`) const res = await this.request.get(`${this.url}/api/users`)
if (res.status() !== 200) if (res.status() !== 200)
@@ -325,7 +321,7 @@ export class ComfyPage {
// window.app.extensionManager => GraphView ready // window.app.extensionManager => GraphView ready
window.app && window.app.extensionManager window.app && window.app.extensionManager
) )
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' }) await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
await this.nextFrame() await this.nextFrame()
} }
@@ -375,7 +371,7 @@ export class ComfyPage {
} }
async closeMenu() { async closeMenu() {
await this.page.click('button.comfy-close-menu-btn') await this.page.locator('button.comfy-close-menu-btn').click()
await this.nextFrame() await this.nextFrame()
} }
@@ -392,9 +388,8 @@ export class ComfyPage {
await modal.waitFor({ state: 'hidden' }) await modal.waitFor({ state: 'hidden' })
} }
/** Get number of DOM widgets on the canvas. */ get domWidgets(): Locator {
async getDOMWidgetCount() { return this.page.locator('.dom-widget')
return await this.page.locator('.dom-widget').count()
} }
async setFocusMode(focusMode: boolean) { async setFocusMode(focusMode: boolean) {

View File

@@ -1,30 +1,22 @@
import type { Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
import { test as base } from '@playwright/test' import { test as base } from '@playwright/test'
export class UserSelectPage { export class UserSelectPage {
public readonly selectionUrl: string
public readonly container: Locator
public readonly newUserInput: Locator
public readonly existingUserSelect: Locator
public readonly nextButton: Locator
constructor( constructor(
public readonly url: string, public readonly url: string,
public readonly page: Page public readonly page: Page
) {} ) {
this.selectionUrl = url + '/user-select'
get selectionUrl() { this.container = page.locator('#comfy-user-selection')
return this.url + '/user-select' this.newUserInput = this.container.locator('#new-user-input')
} this.existingUserSelect = this.container.locator('#existing-user-select')
this.nextButton = this.container.getByText('Next')
get container() {
return this.page.locator('#comfy-user-selection')
}
get newUserInput() {
return this.container.locator('#new-user-input')
}
get existingUserSelect() {
return this.container.locator('#existing-user-select')
}
get nextButton() {
return this.container.getByText('Next')
} }
} }

View File

@@ -7,13 +7,20 @@ import { TestIds } from '@e2e/fixtures/selectors'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures' import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export class VueNodeHelpers { export class VueNodeHelpers {
constructor(private page: Page) {}
/** /**
* Get locator for all Vue node components in the DOM * Get locator for all Vue node components in the DOM
*/ */
get nodes(): Locator { public readonly nodes: Locator
return this.page.locator('[data-node-id]') /**
* Get locator for selected Vue node components (using visual selection indicators)
*/
public readonly selectedNodes: Locator
constructor(private page: Page) {
this.nodes = page.locator('[data-node-id]')
this.selectedNodes = page.locator(
'[data-node-id].outline-node-component-outline'
)
} }
/** /**
@@ -23,13 +30,6 @@ export class VueNodeHelpers {
return this.page.locator(`[data-node-id="${nodeId}"]`) return this.page.locator(`[data-node-id="${nodeId}"]`)
} }
/**
* Get locator for selected Vue node components (using visual selection indicators)
*/
get selectedNodes(): Locator {
return this.page.locator('[data-node-id].outline-node-component-outline')
}
/** /**
* Get locator for Vue nodes by the node's title (displayed name in the header). * 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. * Matches against the actual title element, not the full node body.
@@ -37,7 +37,7 @@ export class VueNodeHelpers {
*/ */
getNodeByTitle(title: string): Locator { getNodeByTitle(title: string): Locator {
return this.page.locator('[data-node-id]').filter({ return this.page.locator('[data-node-id]').filter({
has: this.page.locator('[data-testid="node-title"]', { hasText: title }) has: this.page.getByTestId('node-title').filter({ hasText: title })
}) })
} }
@@ -48,13 +48,6 @@ export class VueNodeHelpers {
return await this.nodes.count() return await this.nodes.count()
} }
/**
* Get count of selected Vue nodes
*/
async getSelectedNodeCount(): Promise<number> {
return await this.selectedNodes.count()
}
/** /**
* Get all Vue node IDs currently in the DOM * Get all Vue node IDs currently in the DOM
*/ */
@@ -153,7 +146,7 @@ export class VueNodeHelpers {
expectedCount expectedCount
) )
} else { } else {
await this.page.waitForSelector('[data-node-id]') await this.page.locator('[data-node-id]').first().waitFor()
} }
} }

View File

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

View File

@@ -3,13 +3,11 @@ import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel { export class ComfyNodeSearchFilterSelectionPanel {
readonly root: Locator readonly root: Locator
readonly header: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
this.root = page.getByRole('dialog') this.root = page.getByRole('dialog')
} this.header = this.root
get header() {
return this.root
.locator('div') .locator('div')
.filter({ hasText: 'Add node filter condition' }) .filter({ hasText: 'Add node filter condition' })
} }
@@ -41,6 +39,8 @@ export class ComfyNodeSearchFilterSelectionPanel {
export class ComfyNodeSearchBox { export class ComfyNodeSearchBox {
public readonly input: Locator public readonly input: Locator
public readonly dropdown: Locator public readonly dropdown: Locator
public readonly filterButton: Locator
public readonly filterChips: Locator
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
@@ -50,13 +50,15 @@ export class ComfyNodeSearchBox {
this.dropdown = page.locator( this.dropdown = page.locator(
'.comfy-vue-node-search-container .p-autocomplete-list' '.comfy-vue-node-search-container .p-autocomplete-list'
) )
this.filterButton = page.locator(
'.comfy-vue-node-search-container .filter-button'
)
this.filterChips = page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page) this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
} }
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container .filter-button')
}
async fillAndSelectFirstNode( async fillAndSelectFirstNode(
nodeName: string, nodeName: string,
options?: { suggestionIndex?: number; exact?: boolean } options?: { suggestionIndex?: number; exact?: boolean }
@@ -78,12 +80,6 @@ export class ComfyNodeSearchBox {
await this.filterSelectionPanel.addFilter(filterValue, filterType) await this.filterSelectionPanel.addFilter(filterValue, filterType)
} }
get filterChips() {
return this.page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
}
async removeFilter(index: number) { async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click() await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
} }

View File

@@ -5,12 +5,14 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export class ComfyNodeSearchBoxV2 { export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator readonly dialog: Locator
readonly input: Locator readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator readonly results: Locator
readonly filterOptions: Locator readonly filterOptions: Locator
constructor(readonly page: Page) { constructor(readonly page: Page) {
this.dialog = page.getByRole('search') this.dialog = page.getByRole('search')
this.input = this.dialog.locator('input[type="text"]') this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId('result-item') this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option') this.filterOptions = this.dialog.getByTestId('filter-option')
} }

View File

@@ -2,18 +2,14 @@ import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
export class ContextMenu { export class ContextMenu {
constructor(public readonly page: Page) {} public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly menuItems: Locator
get primeVueMenu() { constructor(public readonly page: Page) {
return this.page.locator('.p-contextmenu, .p-menu') this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
} this.litegraphMenu = page.locator('.litemenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
get litegraphMenu() {
return this.page.locator('.litemenu')
}
get menuItems() {
return this.page.locator('.p-menuitem, .litemenu-entry')
} }
async clickMenuItem(name: string): Promise<void> { async clickMenuItem(name: string): Promise<void> {
@@ -65,21 +61,9 @@ export class ContextMenu {
} }
async waitForHidden(): Promise<void> { async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()
if (count > 0) {
await locator.waitFor({ state: 'hidden' }).catch((error: Error) => {
console.warn(
`[waitForHidden] ${menuName} waitFor failed:`,
error.message
)
})
}
}
await Promise.all([ await Promise.all([
waitIfExists(this.primeVueMenu, 'primeVueMenu'), this.primeVueMenu.waitFor({ state: 'hidden' }),
waitIfExists(this.litegraphMenu, 'litegraphMenu') this.litegraphMenu.waitFor({ state: 'hidden' })
]) ])
} }
} }

View File

@@ -0,0 +1,97 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '../selectors'
const ids = TestIds.outputHistory
export class OutputHistoryComponent {
constructor(private readonly page: Page) {}
get outputs(): Locator {
return this.page.getByTestId(ids.outputs)
}
get welcome(): Locator {
return this.page.getByTestId(ids.welcome)
}
get outputInfo(): Locator {
return this.page.getByTestId(ids.outputInfo)
}
get activeQueue(): Locator {
return this.page.getByTestId(ids.activeQueue)
}
get queueBadge(): Locator {
return this.page.getByTestId(ids.queueBadge)
}
get inProgressItems(): Locator {
return this.page.getByTestId(ids.inProgressItem)
}
get historyItems(): Locator {
return this.page.getByTestId(ids.historyItem)
}
get skeletons(): Locator {
return this.page.getByTestId(ids.skeleton)
}
get latentPreviews(): Locator {
return this.page.getByTestId(ids.latentPreview)
}
get imageOutputs(): Locator {
return this.page.getByTestId(ids.imageOutput)
}
get videoOutputs(): Locator {
return this.page.getByTestId(ids.videoOutput)
}
/** The currently selected (checked) in-progress item. */
get selectedInProgressItem(): Locator {
return this.page.locator(
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
)
}
/** The currently selected (checked) history item. */
get selectedHistoryItem(): Locator {
return this.page.locator(
`[data-testid="${ids.historyItem}"][data-state="checked"]`
)
}
/** The header-level progress bar. */
get headerProgressBar(): Locator {
return this.page.getByTestId(ids.headerProgressBar)
}
/** The in-progress item's progress bar (inside the thumbnail). */
get itemProgressBar(): Locator {
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
}
/** Overall progress in the header bar. */
get headerOverallProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the header bar. */
get headerNodeProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressNode)
}
/** Overall progress in the in-progress item bar. */
get itemOverallProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the in-progress item bar. */
get itemNodeProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressNode)
}
}

View File

@@ -1,15 +1,22 @@
import type { Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors' import { TestIds } from '@e2e/fixtures/selectors'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog' import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
export class SettingDialog extends BaseDialog { export class SettingDialog extends BaseDialog {
public readonly searchBox: Locator
public readonly categories: Locator
public readonly contentArea: Locator
constructor( constructor(
page: Page, page: Page,
public readonly comfyPage: ComfyPage public readonly comfyPage: ComfyPage
) { ) {
super(page, TestIds.dialogs.settings) super(page, TestIds.dialogs.settings)
this.searchBox = this.root.getByPlaceholder(/Search/)
this.categories = this.root.locator('nav').getByRole('button')
this.contentArea = this.root.getByRole('main')
} }
async open() { async open() {
@@ -36,27 +43,15 @@ export class SettingDialog extends BaseDialog {
await settingInputDiv.locator('input').click() await settingInputDiv.locator('input').click()
} }
get searchBox() {
return this.root.getByPlaceholder(/Search/)
}
get categories() {
return this.root.locator('nav').getByRole('button')
}
category(name: string) { category(name: string) {
return this.root.locator('nav').getByRole('button', { name }) return this.root.locator('nav').getByRole('button', { name })
} }
get contentArea() {
return this.root.getByRole('main')
}
async goToAboutPanel() { async goToAboutPanel() {
const aboutButton = this.root.locator('nav').getByRole('button', { const aboutButton = this.root.locator('nav').getByRole('button', {
name: 'About' name: 'About'
}) })
await aboutButton.click() await aboutButton.click()
await this.page.waitForSelector('.about-container') await this.page.locator('.about-container').waitFor()
} }
} }

View File

@@ -5,18 +5,16 @@ import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors' import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab { class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator
constructor( constructor(
public readonly page: Page, public readonly page: Page,
public readonly tabId: string public readonly tabId: string
) {} ) {
this.tabButton = page.locator(`.${tabId}-tab-button`)
get tabButton() { this.selectedTabButton = page.locator(
return this.page.locator(`.${this.tabId}-tab-button`) `.${tabId}-tab-button.side-bar-button-selected`
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
) )
} }
@@ -35,28 +33,19 @@ class SidebarTab {
} }
export class NodeLibrarySidebarTab extends SidebarTab { export class NodeLibrarySidebarTab extends SidebarTab {
public readonly nodeLibrarySearchBoxInput: Locator
public readonly nodeLibraryTree: Locator
public readonly nodePreview: Locator
public readonly tabContainer: Locator
public readonly newFolderButton: Locator
constructor(public override readonly page: Page) { constructor(public override readonly page: Page) {
super(page, 'node-library') super(page, 'node-library')
} this.nodeLibrarySearchBoxInput = page.getByPlaceholder('Search Nodes...')
this.nodeLibraryTree = page.getByTestId(TestIds.sidebar.nodeLibrary)
get nodeLibrarySearchBoxInput() { this.nodePreview = page.locator('.node-lib-node-preview')
return this.page.getByPlaceholder('Search Nodes...') this.tabContainer = page.locator('.sidebar-content-container')
} this.newFolderButton = this.tabContainer.locator('.new-folder-button')
get nodeLibraryTree() {
return this.page.getByTestId(TestIds.sidebar.nodeLibrary)
}
get nodePreview() {
return this.page.locator('.node-lib-node-preview')
}
get tabContainer() {
return this.page.locator('.sidebar-content-container')
}
get newFolderButton() {
return this.tabContainer.locator('.new-folder-button')
} }
override async open() { override async open() {
@@ -101,34 +90,25 @@ export class NodeLibrarySidebarTab extends SidebarTab {
} }
export class NodeLibrarySidebarTabV2 extends SidebarTab { export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly searchInput: Locator
public readonly sidebarContent: Locator
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
constructor(public override readonly page: Page) { constructor(public override readonly page: Page) {
super(page, 'node-library') super(page, 'node-library')
} this.searchInput = page.getByPlaceholder('Search...')
this.sidebarContent = page.locator('.sidebar-content-container')
get searchInput() { this.allTab = this.getTab('All')
return this.page.getByPlaceholder('Search...') this.blueprintsTab = this.getTab('Blueprints')
} this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
get sidebarContent() {
return this.page.locator('.sidebar-content-container')
} }
getTab(name: string) { getTab(name: string) {
return this.sidebarContent.getByRole('tab', { name, exact: true }) return this.sidebarContent.getByRole('tab', { name, exact: true })
} }
get allTab() {
return this.getTab('All')
}
get blueprintsTab() {
return this.getTab('Blueprints')
}
get sortButton() {
return this.sidebarContent.getByRole('button', { name: 'Sort' })
}
getFolder(folderName: string) { getFolder(folderName: string) {
return this.sidebarContent return this.sidebarContent
.getByRole('treeitem', { name: folderName }) .getByRole('treeitem', { name: folderName })
@@ -154,12 +134,15 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
} }
export class WorkflowsSidebarTab extends SidebarTab { export class WorkflowsSidebarTab extends SidebarTab {
public readonly root: Locator
public readonly activeWorkflowLabel: Locator
constructor(public override readonly page: Page) { constructor(public override readonly page: Page) {
super(page, 'workflows') super(page, 'workflows')
} this.root = page.getByTestId(TestIds.sidebar.workflows)
this.activeWorkflowLabel = this.root.locator(
get root() { '.comfyui-workflows-open .p-tree-node-selected .node-label'
return this.page.getByTestId(TestIds.sidebar.workflows) )
} }
async getOpenedWorkflowNames() { async getOpenedWorkflowNames() {
@@ -169,9 +152,7 @@ export class WorkflowsSidebarTab extends SidebarTab {
} }
async getActiveWorkflowName() { async getActiveWorkflowName() {
return await this.root return await this.activeWorkflowLabel.innerText()
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
.innerText()
} }
async getTopLevelSavedWorkflowNames() { async getTopLevelSavedWorkflowNames() {
@@ -224,36 +205,27 @@ export class WorkflowsSidebarTab extends SidebarTab {
} }
export class ModelLibrarySidebarTab extends SidebarTab { export class ModelLibrarySidebarTab extends SidebarTab {
public readonly searchInput: Locator
public readonly modelTree: Locator
public readonly refreshButton: Locator
public readonly loadAllFoldersButton: Locator
public readonly folderNodes: Locator
public readonly leafNodes: Locator
public readonly modelPreview: Locator
constructor(public override readonly page: Page) { constructor(public override readonly page: Page) {
super(page, 'model-library') super(page, 'model-library')
} this.searchInput = page.getByPlaceholder('Search Models...')
this.modelTree = page.locator('.model-lib-tree-explorer')
get searchInput() { this.refreshButton = page.getByRole('button', { name: 'Refresh' })
return this.page.getByPlaceholder('Search Models...') this.loadAllFoldersButton = page.getByRole('button', {
} name: 'Load All Folders'
})
get modelTree() { this.folderNodes = this.modelTree.locator(
return this.page.locator('.model-lib-tree-explorer') '.p-tree-node:not(.p-tree-node-leaf)'
} )
this.leafNodes = this.modelTree.locator('.p-tree-node-leaf')
get refreshButton() { this.modelPreview = page.locator('.model-lib-model-preview')
return this.page.getByRole('button', { name: 'Refresh' })
}
get loadAllFoldersButton() {
return this.page.getByRole('button', { name: 'Load All Folders' })
}
get folderNodes() {
return this.modelTree.locator('.p-tree-node:not(.p-tree-node-leaf)')
}
get leafNodes() {
return this.modelTree.locator('.p-tree-node-leaf')
}
get modelPreview() {
return this.page.locator('.model-lib-model-preview')
} }
override async open() { override async open() {
@@ -277,137 +249,97 @@ export class ModelLibrarySidebarTab extends SidebarTab {
} }
export class AssetsSidebarTab extends SidebarTab { export class AssetsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'assets')
}
// --- Tab navigation --- // --- Tab navigation ---
public readonly generatedTab: Locator
get generatedTab() { public readonly importedTab: Locator
return this.page.getByRole('tab', { name: 'Generated' })
}
get importedTab() {
return this.page.getByRole('tab', { name: 'Imported' })
}
// --- Empty state --- // --- Empty state ---
public readonly emptyStateMessage: Locator
get emptyStateMessage() { // --- Search & filter ---
return this.page.getByText( public readonly searchInput: Locator
public readonly settingsButton: Locator
// --- View mode ---
public readonly listViewOption: Locator
public readonly gridViewOption: Locator
// --- Sort options (cloud-only, shown inside settings popover) ---
public readonly sortNewestFirst: Locator
public readonly sortOldestFirst: Locator
// --- Asset cards ---
public readonly assetCards: Locator
public readonly selectedCards: Locator
// --- List view items ---
public readonly listViewItems: Locator
// --- Selection footer ---
public readonly selectionFooter: Locator
public readonly selectionCountButton: Locator
public readonly deselectAllButton: Locator
public readonly deleteSelectedButton: Locator
public readonly downloadSelectedButton: Locator
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
constructor(public override readonly page: Page) {
super(page, 'assets')
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
this.importedTab = page.getByRole('tab', { name: 'Imported' })
this.emptyStateMessage = page.getByText(
'Upload files or generate content to see them here' 'Upload files or generate content to see them here'
) )
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.sortNewestFirst = page.getByText('Newest first')
this.sortOldestFirst = page.getByText('Oldest first')
this.assetCards = page
.getByRole('button')
.and(page.locator('[data-selected]'))
this.selectedCards = page.locator('[data-selected="true"]')
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
this.selectionFooter = page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
this.deselectAllButton = page.getByText('Deselect all')
this.deleteSelectedButton = page
.getByTestId('assets-delete-selected')
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
.first()
this.downloadSelectedButton = page
.getByTestId('assets-download-selected')
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
this.backToAssetsButton = page.getByText('Back to all assets')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)
} }
emptyStateTitle(title: string) { emptyStateTitle(title: string) {
return this.page.getByText(title) return this.page.getByText(title)
} }
// --- Search & filter ---
get searchInput() {
return this.page.getByPlaceholder('Search Assets...')
}
get settingsButton() {
return this.page.getByRole('button', { name: 'View settings' })
}
// --- View mode ---
get listViewOption() {
return this.page.getByText('List view')
}
get gridViewOption() {
return this.page.getByText('Grid view')
}
// --- Sort options (cloud-only, shown inside settings popover) ---
get sortNewestFirst() {
return this.page.getByText('Newest first')
}
get sortOldestFirst() {
return this.page.getByText('Oldest first')
}
// --- Asset cards ---
get assetCards() {
return this.page.locator('[role="button"][data-selected]')
}
getAssetCardByName(name: string) { getAssetCardByName(name: string) {
return this.page.locator('[role="button"][data-selected]', { return this.assetCards.filter({ hasText: name })
hasText: name
})
} }
get selectedCards() {
return this.page.locator('[data-selected="true"]')
}
// --- List view items ---
get listViewItems() {
return this.page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
}
// --- Selection footer ---
get selectionFooter() {
return this.page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
}
get selectionCountButton() {
return this.page.getByText(/Assets Selected: \d+/)
}
get deselectAllButton() {
return this.page.getByText('Deselect all')
}
get deleteSelectedButton() {
return this.page
.getByTestId('assets-delete-selected')
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
.first()
}
get downloadSelectedButton() {
return this.page
.getByTestId('assets-download-selected')
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
}
// --- Context menu ---
contextMenuItem(label: string) { contextMenuItem(label: string) {
return this.page.locator('.p-contextmenu').getByText(label) return this.page.locator('.p-contextmenu').getByText(label)
} }
// --- Folder view ---
get backToAssetsButton() {
return this.page.getByText('Back to all assets')
}
// --- Loading ---
get skeletonLoaders() {
return this.page.locator('.sidebar-content-container .animate-pulse')
}
// --- Helpers ---
override async open() { override async open() {
// Remove any toast notifications that may overlay the sidebar button // Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts() await this.dismissToasts()
@@ -419,28 +351,24 @@ export class AssetsSidebarTab extends SidebarTab {
async dismissToasts() { async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button') const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) { for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {}) await btn.click().catch(() => {})
} }
// Wait for all toast elements to fully animate out and detach from DOM // Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message')) await expect(this.page.locator('.p-toast-message'))
.toHaveCount(0, { timeout: 5000 }) .toHaveCount(0)
.catch(() => {}) .catch(() => {})
} }
async switchToImported() { async switchToImported() {
await this.dismissToasts() await this.dismissToasts()
await this.importedTab.click() await this.importedTab.click()
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', { await expect(this.importedTab).toHaveAttribute('aria-selected', 'true')
timeout: 3000
})
} }
async switchToGenerated() { async switchToGenerated() {
await this.dismissToasts() await this.dismissToasts()
await this.generatedTab.click() await this.generatedTab.click()
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', { await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true')
timeout: 3000
})
} }
async openSettingsMenu() { async openSettingsMenu() {
@@ -463,7 +391,7 @@ export class AssetsSidebarTab extends SidebarTab {
async waitForAssets(count?: number) { async waitForAssets(count?: number) {
if (count !== undefined) { if (count !== undefined) {
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 }) await expect(this.assetCards).toHaveCount(count)
} else { } else {
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 }) await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
} }

View File

@@ -10,6 +10,17 @@ export class SignInDialog extends BaseDialog {
readonly apiKeyButton: Locator readonly apiKeyButton: Locator
readonly termsLink: Locator readonly termsLink: Locator
readonly privacyLink: Locator readonly privacyLink: Locator
readonly heading: Locator
readonly signUpLink: Locator
readonly signInLink: Locator
readonly signUpEmailInput: Locator
readonly signUpPasswordInput: Locator
readonly signUpConfirmPasswordInput: Locator
readonly signUpButton: Locator
readonly apiKeyHeading: Locator
readonly apiKeyInput: Locator
readonly backButton: Locator
readonly dividerText: Locator
constructor(page: Page) { constructor(page: Page) {
super(page) super(page)
@@ -22,6 +33,22 @@ export class SignInDialog extends BaseDialog {
}) })
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' }) this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' }) this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
this.heading = this.root.getByRole('heading').first()
this.signUpLink = this.root.getByText('Sign up', { exact: true })
this.signInLink = this.root.getByText('Sign in', { exact: true })
this.signUpEmailInput = this.root.locator('#comfy-org-sign-up-email')
this.signUpPasswordInput = this.root.locator('#comfy-org-sign-up-password')
this.signUpConfirmPasswordInput = this.root.locator(
'#comfy-org-sign-up-confirm-password'
)
this.signUpButton = this.root.getByRole('button', {
name: 'Sign up',
exact: true
})
this.apiKeyHeading = this.root.getByRole('heading', { name: 'API Key' })
this.apiKeyInput = this.root.locator('#comfy-org-api-key')
this.backButton = this.root.getByRole('button', { name: 'Back' })
this.dividerText = this.root.getByText('Or continue with')
} }
async open() { async open() {
@@ -30,48 +57,4 @@ export class SignInDialog extends BaseDialog {
}) })
await this.waitForVisible() 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

@@ -5,10 +5,12 @@ import type { WorkspaceStore } from '@e2e/types/globals'
export class Topbar { export class Topbar {
private readonly menuLocator: Locator private readonly menuLocator: Locator
private readonly menuTrigger: Locator private readonly menuTrigger: Locator
readonly newWorkflowButton: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu') this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper') this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
} }
async getTabNames(): Promise<string[]> { async getTabNames(): Promise<string[]> {
@@ -50,10 +52,6 @@ export class Topbar {
return classes ? !classes.includes('invisible') : false return classes ? !classes.includes('invisible') : false
} }
get newWorkflowButton(): Locator {
return this.page.locator('.new-blank-workflow-button')
}
getWorkflowTab(tabName: string): Locator { getWorkflowTab(tabName: string): Locator {
return this.page return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`) .locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
@@ -73,7 +71,7 @@ export class Topbar {
async closeWorkflowTab(tabName: string) { async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName) const tab = this.getWorkflowTab(tabName)
await tab.hover() await tab.hover()
await tab.locator('.close-button').click({ force: true }) await tab.locator('.close-button').click()
} }
getSaveDialog(): Locator { getSaveDialog(): Locator {
@@ -107,7 +105,7 @@ export class Topbar {
{ timeout: 3000 } { timeout: 3000 }
) )
// Wait for the dialog to close. // Wait for the dialog to close.
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 }) await this.getSaveDialog().waitFor({ state: 'hidden' })
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?") // Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation // If so, return early to let the test handle the confirmation

View File

@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors' import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper' import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper' import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper' import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -14,14 +15,66 @@ export class AppModeHelper {
readonly footer: BuilderFooterHelper readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly widgets: AppModeWidgetHelper readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
/** The empty-state placeholder shown when no outputs are selected. */
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
public readonly runButton: Locator
/** The welcome screen shown when app mode has no outputs or no nodes. */
public readonly welcome: Locator
/** The empty workflow message shown when no nodes exist. */
public readonly emptyWorkflowText: Locator
/** The "Build app" button shown when nodes exist but no outputs. */
public readonly buildAppButton: Locator
/** The "Back to workflow" button on the welcome screen. */
public readonly backToWorkflowButton: Locator
/** The "Load template" button shown when no nodes exist. */
public readonly loadTemplateButton: Locator
/** The cancel button for an in-progress run in the output history. */
public readonly cancelRunButton: Locator
constructor(private readonly comfyPage: ComfyPage) { constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage) this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage) this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage) this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage) this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.widgets = new AppModeWidgetHelper(comfyPage) this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
)
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(
TestIds.appMode.emptyWorkflow
)
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
this.backToWorkflowButton = this.page.getByTestId(
TestIds.appMode.backToWorkflow
)
this.loadTemplateButton = this.page.getByTestId(
TestIds.appMode.loadTemplate
)
this.cancelRunButton = this.page.getByTestId(
TestIds.outputHistory.cancelRun
)
} }
private get page(): Page { private get page(): Page {
@@ -93,61 +146,6 @@ export class AppModeHelper {
await this.toggleAppMode() await this.toggleAppMode()
} }
/** The "Connect an output" popover shown when saving without outputs. */
get connectOutputPopover(): Locator {
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
}
/** The empty-state placeholder shown when no outputs are selected. */
get outputPlaceholder(): Locator {
return this.page.getByTestId(TestIds.builder.outputPlaceholder)
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
}
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
get imagePickerPopover(): Locator {
return this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
}
/** The Run button in the app mode footer. */
get runButton(): Locator {
return this.page
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
}
/** The welcome screen shown when app mode has no outputs or no nodes. */
get welcome(): Locator {
return this.page.getByTestId(TestIds.appMode.welcome)
}
/** The empty workflow message shown when no nodes exist. */
get emptyWorkflowText(): Locator {
return this.page.getByTestId(TestIds.appMode.emptyWorkflow)
}
/** The "Build app" button shown when nodes exist but no outputs. */
get buildAppButton(): Locator {
return this.page.getByTestId(TestIds.appMode.buildApp)
}
/** The "Back to workflow" button on the welcome screen. */
get backToWorkflowButton(): Locator {
return this.page.getByTestId(TestIds.appMode.backToWorkflow)
}
/** The "Load template" button shown when no nodes exist. */
get loadTemplateButton(): Locator {
return this.page.getByTestId(TestIds.appMode.loadTemplate)
}
/** /**
* Get the actions menu trigger for a widget in the app mode widget list. * Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed"). * @param widgetName Text shown in the widget label (e.g. "seed").

View File

@@ -4,48 +4,32 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors' import { TestIds } from '@e2e/fixtures/selectors'
export class BuilderFooterHelper { export class BuilderFooterHelper {
constructor(private readonly comfyPage: ComfyPage) {} public readonly nav: Locator
public readonly exitButton: Locator
public readonly nextButton: Locator
public readonly backButton: Locator
public readonly saveButton: Locator
public readonly saveGroup: Locator
public readonly saveAsButton: Locator
public readonly saveAsChevron: Locator
public readonly opensAsPopover: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.nav = this.page.getByTestId(TestIds.builder.footerNav)
this.exitButton = this.buttonByName('Exit app builder')
this.nextButton = this.buttonByName('Next')
this.backButton = this.buttonByName('Back')
this.saveButton = this.page.getByTestId(TestIds.builder.saveButton)
this.saveGroup = this.page.getByTestId(TestIds.builder.saveGroup)
this.saveAsButton = this.page.getByTestId(TestIds.builder.saveAsButton)
this.saveAsChevron = this.page.getByTestId(TestIds.builder.saveAsChevron)
this.opensAsPopover = this.page.getByTestId(TestIds.builder.opensAs)
}
private get page(): Page { private get page(): Page {
return this.comfyPage.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 saveGroup(): Locator {
return this.page.getByTestId(TestIds.builder.saveGroup)
}
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 { private buttonByName(name: string): Locator {
return this.nav.getByRole('button', { name }) return this.nav.getByRole('button', { name })
} }

View File

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

View File

@@ -32,7 +32,20 @@ async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
} }
export class BuilderSelectHelper { export class BuilderSelectHelper {
constructor(private readonly comfyPage: ComfyPage) {} /** All IoItem locators in the current step sidebar. */
public readonly inputItems: Locator
/** All IoItem title locators in the inputs step sidebar. */
public readonly inputItemTitles: Locator
/** All widget label locators in the preview/arrange sidebar. */
public readonly previewWidgetLabels: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.inputItems = this.page.getByTestId(TestIds.builder.ioItem)
this.inputItemTitles = this.page.getByTestId(TestIds.builder.ioItemTitle)
this.previewWidgetLabels = this.page.getByTestId(
TestIds.builder.widgetLabel
)
}
private get page(): Page { private get page(): Page {
return this.comfyPage.page return this.comfyPage.page
@@ -43,12 +56,9 @@ export class BuilderSelectHelper {
* @param title The widget title shown in the IoItem. * @param title The widget title shown in the IoItem.
*/ */
getInputItemMenu(title: string): Locator { getInputItemMenu(title: string): Locator {
return this.page return this.inputItems
.getByTestId(TestIds.builder.ioItem)
.filter({ .filter({
has: this.page has: this.inputItemTitles.getByText(title, { exact: true })
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
}) })
.getByTestId(TestIds.builder.widgetActionsMenu) .getByTestId(TestIds.builder.widgetActionsMenu)
} }
@@ -141,6 +151,7 @@ export class BuilderSelectHelper {
const widgetLocator = this.comfyPage.vueNodes const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id)) .getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true }) .getByLabel(widgetName, { exact: true })
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await widgetLocator.click({ force: true }) await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame() await this.comfyPage.nextFrame()
} }
@@ -150,38 +161,19 @@ export class BuilderSelectHelper {
* Useful for asserting "Widget not visible" on disconnected inputs. * Useful for asserting "Widget not visible" on disconnected inputs.
*/ */
getInputItemSubtitle(title: string): Locator { getInputItemSubtitle(title: string): Locator {
return this.page return this.inputItems
.getByTestId(TestIds.builder.ioItem)
.filter({ .filter({
has: this.page has: this.inputItemTitles.getByText(title, { exact: true })
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
}) })
.getByTestId(TestIds.builder.ioItemSubtitle) .getByTestId(TestIds.builder.ioItemSubtitle)
} }
/** All IoItem locators in the current step sidebar. */
get inputItems(): Locator {
return this.page.getByTestId(TestIds.builder.ioItem)
}
/** All IoItem title locators in the inputs step sidebar. */
get inputItemTitles(): Locator {
return this.page.getByTestId(TestIds.builder.ioItemTitle)
}
/** All widget label locators in the preview/arrange sidebar. */
get previewWidgetLabels(): Locator {
return this.page.getByTestId(TestIds.builder.widgetLabel)
}
/** /**
* Drag an IoItem from one index to another in the inputs step. * Drag an IoItem from one index to another in the inputs step.
* Items are identified by their 0-based position among visible IoItems. * Items are identified by their 0-based position among visible IoItems.
*/ */
async dragInputItem(fromIndex: number, toIndex: number) { async dragInputItem(fromIndex: number, toIndex: number) {
const items = this.page.getByTestId(TestIds.builder.ioItem) await dragByIndex(this.inputItems, fromIndex, toIndex)
await dragByIndex(items, fromIndex, toIndex)
await this.comfyPage.nextFrame() await this.comfyPage.nextFrame()
} }
@@ -208,6 +200,7 @@ export class BuilderSelectHelper {
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator( const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
String(nodeRef.id) String(nodeRef.id)
) )
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await nodeLocator.click({ force: true }) await nodeLocator.click({ force: true })
await this.comfyPage.nextFrame() await this.comfyPage.nextFrame()
} }

View File

@@ -3,16 +3,16 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export class BuilderStepsHelper { export class BuilderStepsHelper {
constructor(private readonly comfyPage: ComfyPage) {} public readonly toolbar: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.toolbar = this.page.getByRole('navigation', { name: 'App Builder' })
}
private get page(): Page { private get page(): Page {
return this.comfyPage.page return this.comfyPage.page
} }
get toolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
async goToInputs() { async goToInputs() {
await this.toolbar.getByRole('button', { name: 'Inputs' }).click() await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame() await this.comfyPage.nextFrame()

View File

@@ -74,6 +74,51 @@ export class CanvasHelper {
await this.nextFrame() await this.nextFrame()
} }
/**
* Convert a canvas-element-relative position to absolute page coordinates.
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
private async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
}
/**
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
* Bypasses Playwright's actionability checks on the canvas locator, which
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
*/
async mouseClickAt(
position: Position,
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const abs = await this.toAbsolute(position)
const modifiers = options?.modifiers ?? []
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.page.mouse.click(abs.x, abs.y, {
button: options?.button
})
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
}
/**
* Double-click at canvas-element-relative coordinates using `page.mouse`.
*/
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> { async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick }) await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame() await this.nextFrame()

View File

@@ -0,0 +1,211 @@
import type { WebSocketRoute } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '../ComfyPage'
import { createMockJob } from './AssetsHelper'
/**
* Helper for simulating prompt execution in e2e tests.
*/
export class ExecutionHelper {
private jobCounter = 0
private readonly completedJobs: RawJobListItem[] = []
private readonly page: ComfyPage['page']
private readonly command: ComfyPage['command']
private readonly assets: ComfyPage['assets']
constructor(
comfyPage: ComfyPage,
private readonly ws: WebSocketRoute
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
}
/**
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
* the synthetic job ID.
*
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
fulfilled = r
})
await this.page.route(
'**/api/prompt',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: {}
})
})
fulfilled()
},
{ times: 1 }
)
await this.command.executeCommand('Comfy.QueuePrompt')
await prompted
return jobId
}
/**
* Send a binary `b_preview_with_metadata` WS message (type 4).
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
*/
latentPreview(jobId: string, nodeId: string): void {
const metadata = JSON.stringify({
node_id: nodeId,
display_node_id: nodeId,
parent_node_id: nodeId,
real_node_id: nodeId,
prompt_id: jobId,
image_type: 'image/png'
})
const metadataBytes = new TextEncoder().encode(metadata)
// 1x1 red PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
'base64'
)
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
const view = new DataView(buf)
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
view.setUint32(4, metadataBytes.length)
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
this.ws.send(Buffer.from(buf))
}
/** Send `execution_start` WS event. */
executionStart(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_start',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `executing` WS event to signal which node is currently running. */
executing(jobId: string, nodeId: string | null): void {
this.ws.send(
JSON.stringify({
type: 'executing',
data: { prompt_id: jobId, node: nodeId }
})
)
}
/** Send `executed` WS event with node output. */
executed(
jobId: string,
nodeId: string,
output: Record<string, unknown>
): void {
this.ws.send(
JSON.stringify({
type: 'executed',
data: {
prompt_id: jobId,
node: nodeId,
display_node: nodeId,
output
}
})
)
}
/** Send `execution_success` WS event. */
executionSuccess(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_success',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `execution_error` WS event. */
executionError(jobId: string, nodeId: string, message: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_error',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
exception_message: message,
exception_type: 'RuntimeError',
traceback: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.ws.send(
JSON.stringify({
type: 'progress',
data: { prompt_id: jobId, node: nodeId, value, max }
})
)
}
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.
*
* Requires an {@link AssetsHelper} to be passed in the constructor.
*/
async completeWithHistory(
jobId: string,
nodeId: string,
filename: string
): Promise<void> {
this.completedJobs.push(
createMockJob({
id: jobId,
preview_output: {
filename,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
})
)
await this.assets.mockOutputHistory(this.completedJobs)
this.executionSuccess(jobId)
// Trigger queue/history refresh
this.status(0)
}
/** Send `status` WS event to update queue count. */
status(queueRemaining: number): void {
this.ws.send(
JSON.stringify({
type: 'status',
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
})
)
}
}

View File

@@ -0,0 +1,25 @@
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
import { Load3DViewerHelper } from '@e2e/tests/load3d/Load3DViewerHelper'
export const load3dTest = comfyPageFixture.extend<{
load3d: Load3DHelper
}>({
load3d: async ({ comfyPage }, use) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeLocator('1')
await use(new Load3DHelper(node))
}
})
export const load3dViewerTest = load3dTest.extend<{
viewer: Load3DViewerHelper
}>({
viewer: async ({ comfyPage }, use) => {
await comfyPage.settings.setSetting('Comfy.Load3D.3DViewerEnable', true)
await use(new Load3DViewerHelper(comfyPage.page))
}
})

View File

@@ -1,6 +1,10 @@
import type { Locator } from '@playwright/test' import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions' import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
@@ -8,7 +12,13 @@ import type { Position, Size } from '@e2e/fixtures/types'
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils' import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
export class NodeOperationsHelper { export class NodeOperationsHelper {
constructor(private comfyPage: ComfyPage) {} public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
}
private get page() { private get page() {
return this.comfyPage.page return this.comfyPage.page
@@ -30,6 +40,45 @@ export class NodeOperationsHelper {
}) })
} }
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
cursorPosition?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, cursor]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && cursor) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: cursor.x,
clientY: cursor.y
})
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, cursorPosition ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Remove all nodes from the graph and clean. */ /** Remove all nodes from the graph and clean. */
async clearGraph() { async clearGraph() {
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false) await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
@@ -155,10 +204,6 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame() await this.comfyPage.nextFrame()
} }
get promptDialogInput(): Locator {
return this.page.locator('.p-dialog-content input[type="text"]')
}
async fillPromptDialog(value: string): Promise<void> { async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value) await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter') await this.page.keyboard.press('Enter')

View File

@@ -157,7 +157,7 @@ export class SubgraphHelper {
// Wait for the appropriate UI element to appear // Wait for the appropriate UI element to appear
if (action === 'rightClick') { if (action === 'rightClick') {
await this.page.waitForSelector('.litemenu-entry', { await this.page.locator('.litemenu-entry').first().waitFor({
state: 'visible', state: 'visible',
timeout: 5000 timeout: 5000
}) })
@@ -445,7 +445,7 @@ export class SubgraphHelper {
await this.rightClickOutputSlot(slotName) await this.rightClickOutputSlot(slotName)
} }
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot') await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await this.comfyPage.nextFrame() await this.comfyPage.contextMenu.waitForHidden()
} }
async findSubgraphNodeId(): Promise<string> { async findSubgraphNodeId(): Promise<string> {

View File

@@ -2,20 +2,12 @@ import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
export class ToastHelper { export class ToastHelper {
constructor(private readonly page: Page) {} public readonly visibleToasts: Locator
public readonly toastErrors: Locator
get visibleToasts(): Locator { constructor(private readonly page: Page) {
return this.page.locator('.p-toast-message:visible') this.visibleToasts = page.locator('.p-toast-message:visible')
} this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
async getToastErrorCount(): Promise<number> {
return await this.page
.locator('.p-toast-message.p-toast-message-error')
.count()
}
async getVisibleToastCount(): Promise<number> {
return await this.visibleToasts.count()
} }
async closeToasts(requireCount = 0): Promise<void> { async closeToasts(requireCount = 0): Promise<void> {
@@ -34,6 +26,6 @@ export class ToastHelper {
} }
// Assert all toasts are closed // Assert all toasts are closed
await expect(this.visibleToasts).toHaveCount(0, { timeout: 1000 }) await expect(this.visibleToasts).toHaveCount(0)
} }
} }

View File

@@ -21,6 +21,10 @@ export const TestIds = {
contextMenu: 'canvas-context-menu', contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button', toggleMinimapButton: 'toggle-minimap-button',
closeMinimapButton: 'close-minimap-button', closeMinimapButton: 'close-minimap-button',
minimapContainer: 'minimap-container',
minimapCanvas: 'minimap-canvas',
minimapViewport: 'minimap-viewport',
minimapInteractionOverlay: 'minimap-interaction-overlay',
toggleLinkVisibilityButton: 'toggle-link-visibility-button', toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button', zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action', zoomInAction: 'zoom-in-action',
@@ -130,6 +134,24 @@ export const TestIds = {
outputPlaceholder: 'builder-output-placeholder', outputPlaceholder: 'builder-output-placeholder',
connectOutputPopover: 'builder-connect-output-popover' connectOutputPopover: 'builder-connect-output-popover'
}, },
outputHistory: {
outputs: 'linear-outputs',
welcome: 'linear-welcome',
outputInfo: 'linear-output-info',
activeQueue: 'linear-job',
queueBadge: 'linear-job-badge',
inProgressItem: 'linear-in-progress-item',
historyItem: 'linear-history-item',
skeleton: 'linear-skeleton',
latentPreview: 'linear-latent-preview',
imageOutput: 'linear-image-output',
videoOutput: 'linear-video-output',
cancelRun: 'linear-cancel-run',
headerProgressBar: 'linear-header-progress-bar',
itemProgressBar: 'linear-item-progress-bar',
progressOverall: 'linear-progress-overall',
progressNode: 'linear-progress-node'
},
appMode: { appMode: {
widgetItem: 'app-mode-widget-item', widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome', welcome: 'linear-welcome',
@@ -155,6 +177,12 @@ export const TestIds = {
errors: { errors: {
imageLoadError: 'error-loading-image', imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video' videoLoadError: 'error-loading-video'
},
loading: {
overlay: 'loading-overlay'
},
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
} }
} as const } as const
@@ -174,6 +202,7 @@ export type TestIdValue =
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox] | (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets] | (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder] | (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode] | (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb] | (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude< | Exclude<
@@ -185,3 +214,5 @@ export type TestIdValue =
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor] | (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
| (typeof TestIds.queue)[keyof typeof TestIds.queue] | (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors] | (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]

View File

@@ -14,11 +14,12 @@ function makeMatcher<T>(
) { ) {
await expect(async () => { await expect(async () => {
const value = await getValue(node) const value = await getValue(node)
const assertion = this.isNot if (this.isNot) {
? expect(value, 'Node is ' + type).not expect(value, 'Node is ' + type).not.toBeTruthy()
: expect(value, 'Node is not ' + type) } else {
assertion.toBeTruthy() expect(value, 'Node is not ' + type).toBeTruthy()
}).toPass({ timeout: 250, ...options }) }
}).toPass({ timeout: 5000, ...options })
return { return {
pass: !this.isNot, pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
@@ -30,7 +31,7 @@ export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'), toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'), toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'), toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
async toHaveFocus(locator: Locator, options = { timeout: 256 }) { async toHaveFocus(locator: Locator, options = {}) {
await expect await expect
.poll( .poll(
() => locator.evaluate((el) => el === document.activeElement), () => locator.evaluate((el) => el === document.activeElement),

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode' import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
@@ -356,7 +355,11 @@ export class NodeReference {
} }
async click( async click(
position: 'title' | 'collapse', position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean } options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
moveMouseToEmptyArea?: boolean
}
) { ) {
let clickPos: Position let clickPos: Position
switch (position) { switch (position) {
@@ -377,12 +380,7 @@ export class NodeReference {
delete options.moveMouseToEmptyArea delete options.moveMouseToEmptyArea
} }
await this.comfyPage.canvas.click({ await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
...options,
position: clickPos,
force: true
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) { if (moveMouseToEmptyArea) {
await this.comfyPage.canvasOps.moveMouseToEmptyArea() await this.comfyPage.canvasOps.moveMouseToEmptyArea()
} }
@@ -499,31 +497,18 @@ export class NodeReference {
await expect(async () => { await expect(async () => {
// Try just clicking the enter button first // Try just clicking the enter button first
await this.comfyPage.canvas.click({ await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click({ await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
position: subgraphButtonPos,
force: true
})
await this.comfyPage.nextFrame()
if (await checkIsInSubgraph()) return if (await checkIsInSubgraph()) return
for (const position of clickPositions) { for (const position of clickPositions) {
// Clear any selection first // Clear any selection first
await this.comfyPage.canvas.click({ await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
// Double-click to enter subgraph // Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true }) await this.comfyPage.canvasOps.mouseDblclickAt(position)
await this.comfyPage.nextFrame()
if (await checkIsInSubgraph()) return if (await checkIsInSubgraph()) return
} }

View File

@@ -4,38 +4,24 @@ import { TestIds } from '@e2e/fixtures/selectors'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */ /** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture { export class VueNodeFixture {
constructor(private readonly locator: Locator) {} public readonly header: Locator
public readonly title: Locator
public readonly titleInput: Locator
public readonly body: Locator
public readonly pinIndicator: Locator
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
get header(): Locator { constructor(private readonly locator: Locator) {
return this.locator.locator('[data-testid^="node-header-"]') this.header = locator.locator('[data-testid^="node-header-"]')
} this.title = locator.getByTestId('node-title')
this.titleInput = locator.getByTestId('node-title-input')
get title(): Locator { this.body = locator.locator('[data-testid^="node-body-"]')
return this.locator.locator('[data-testid="node-title"]') this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
} this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
get titleInput(): Locator { this.root = locator
return this.locator.locator('[data-testid="node-title-input"]')
}
get body(): Locator {
return this.locator.locator('[data-testid^="node-body-"]')
}
get pinIndicator(): Locator {
return this.locator.getByTestId(TestIds.node.pinIndicator)
}
get collapseButton(): Locator {
return this.locator.locator('[data-testid="node-collapse-button"]')
}
get collapseIcon(): Locator {
return this.collapseButton.locator('i')
}
get root(): Locator {
return this.locator
} }
async getTitle(): Promise<string> { async getTitle(): Promise<string> {

View File

@@ -1,53 +1,31 @@
import { test as base } from '@playwright/test' import { test as base } from '@playwright/test'
import type { WebSocketRoute } from '@playwright/test'
export const webSocketFixture = base.extend<{ export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> } getWebSocket: () => Promise<WebSocketRoute>
}>({ }>({
ws: [ getWebSocket: [
async ({ page }, use) => { async ({ context }, use) => {
// Each time a page loads, to catch navigations let latest: WebSocketRoute | undefined
page.on('load', async () => { let resolve: ((ws: WebSocketRoute) => void) | undefined
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally await context.routeWebSocket(/\/ws/, (ws) => {
// so we can look it up to trigger messages const server = ws.connectToServer()
const store: Record<string, WebSocket> = (window.__ws__ = {}) server.onMessage((message) => {
window.WebSocket = class extends window.WebSocket { ws.send(message)
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}
}) })
latest = ws
resolve?.(ws)
}) })
await use({ await use(() => {
async trigger(data, url) { if (latest) return Promise.resolve(latest)
// Trigger a websocket event on the page return new Promise<WebSocketRoute>((r) => {
await page.evaluate( resolve = r
function ([data, url]) { })
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.href)
u.hash = ''
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = window.__ws__![url]
ws.dispatchEvent(
new MessageEvent('message', {
data
})
)
},
[JSON.stringify(data), url]
)
}
}) })
}, },
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
{ auto: true } { auto: true }
] ]
}) })

View File

@@ -82,11 +82,9 @@ export async function builderSaveAs(
viewType: 'App' | 'Node graph' = 'App' viewType: 'App' | 'Node graph' = 'App'
) { ) {
await appMode.footer.saveAsButton.click() await appMode.footer.saveAsButton.click()
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 }) await comfyExpect(appMode.saveAs.nameInput).toBeVisible()
await appMode.saveAs.fillAndSave(workflowName, viewType) await appMode.saveAs.fillAndSave(workflowName, viewType)
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({ await comfyExpect(appMode.saveAs.successMessage).toBeVisible()
timeout: 5000
})
} }
/** /**
@@ -124,3 +122,21 @@ export async function saveAndReopenInAppMode(
await comfyPage.appMode.toggleAppMode() await comfyPage.appMode.toggleAppMode()
} }
/**
* Enter builder, select the given widgets as inputs + SaveImage as output,
* save as an app, and close the success dialog.
*
* Returns on the builder arrange/preview step.
*/
export async function createAndSaveApp(
comfyPage: ComfyPage,
appName: string,
widgetNames: string[] = ['seed']
): Promise<void> {
await setupBuilder(comfyPage, undefined, widgetNames)
await comfyPage.appMode.steps.goToPreview()
await builderSaveAs(comfyPage.appMode, appName)
await comfyPage.appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
}

View File

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

View File

@@ -0,0 +1,66 @@
import type { Locator, Page } from '@playwright/test'
import type { TestGraphAccess } from '@e2e/types/globals'
export async function drawStroke(
page: Page,
canvas: Locator,
opts: { startXPct?: number; endXPct?: number; yPct?: number } = {}
): Promise<void> {
const { startXPct = 0.3, endXPct = 0.7, yPct = 0.5 } = opts
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
await page.mouse.move(
box.x + box.width * startXPct,
box.y + box.height * yPct
)
await page.mouse.down()
await page.mouse.move(
box.x + box.width * endXPct,
box.y + box.height * yPct,
{ steps: 10 }
)
await page.mouse.up()
}
export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
return canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const { data } = ctx.getImageData(0, 0, el.width, el.height)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true
}
return false
})
}
export async function triggerSerialization(page: Page): Promise<void> {
await page.evaluate(async () => {
const graph = window.graph as TestGraphAccess | undefined
if (!graph) {
throw new Error(
'Global window.graph is absent. Ensure workflow fixture is loaded.'
)
}
const node = graph._nodes_by_id?.['1']
if (!node) {
throw new Error(
'Target node with ID "1" not found in graph._nodes_by_id.'
)
}
const widget = node.widgets?.find((w) => w.name === 'mask')
if (!widget) {
throw new Error('Widget "mask" not found on target node 1.')
}
if (typeof widget.serializeValue !== 'function') {
throw new Error(
'mask widget on node 1 does not have a serializeValue function.'
)
}
await widget.serializeValue(node, 0)
})
}

View File

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

View File

@@ -1,7 +1,6 @@
import type { Response } from '@playwright/test' import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test' import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '@/schemas/apiSchema'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws' import { webSocketFixture } from '@e2e/fixtures/ws'
import type { WorkspaceStore } from '@e2e/types/globals' import type { WorkspaceStore } from '@e2e/types/globals'
@@ -18,14 +17,16 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
*/ */
test('Does not auto-queue multiple changes at a time', async ({ test('Does not auto-queue multiple changes at a time', async ({
comfyPage, comfyPage,
ws getWebSocket
}) => { }) => {
const ws = await getWebSocket()
// Enable change auto-queue mode // Enable change auto-queue mode
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions() const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
expect(await queueOpts.getMode()).toBe('disabled') await expect.poll(() => queueOpts.getMode()).toBe('disabled')
await queueOpts.setMode('change') await queueOpts.setMode('change')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await queueOpts.getMode()).toBe('change') await expect.poll(() => queueOpts.getMode()).toBe('change')
await comfyPage.actionbar.queueButton.toggleOptions() await comfyPage.actionbar.queueButton.toggleOptions()
// Intercept the prompt queue endpoint // Intercept the prompt queue endpoint
@@ -62,17 +63,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
} }
// Trigger a status websocket message // Trigger a status websocket message
const triggerStatus = async (queueSize: number) => { const triggerStatus = (queueSize: number) => {
await ws.trigger({ ws.send(
type: 'status', JSON.stringify({
data: { type: 'status',
status: { data: {
exec_info: { status: {
queue_remaining: queueSize exec_info: {
queue_remaining: queueSize
}
} }
} }
} })
} as StatusWsMessage) )
} }
// Extract the width from the queue response // Extract the width from the queue response
@@ -104,8 +107,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
).toBe(1) ).toBe(1)
// Trigger a status update so auto-queue re-runs // Trigger a status update so auto-queue re-runs
await triggerStatus(1) triggerStatus(1)
await triggerStatus(0) triggerStatus(0)
// Ensure the queued width is the last queued value // Ensure the queued width is the last queued value
expect( expect(
@@ -124,6 +127,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
force: true force: true
} }
) )
expect(await comfyPage.actionbar.isDocked()).toBe(true) await expect(comfyPage.actionbar.root.locator('.actionbar')).toHaveClass(
/static/
)
}) })
}) })

View File

@@ -75,9 +75,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
] ]
await comfyPage.appMode.enterAppModeWithInputs(inputs) await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({ await expect(comfyPage.appMode.linearWidgets).toBeVisible()
timeout: 5000
})
// Scroll to bottom so the codec widget is at the clipping edge // Scroll to bottom so the codec widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets const widgetList = comfyPage.appMode.linearWidgets
@@ -90,21 +88,25 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
await codecSelect.click() await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first() const overlay = comfyPage.page.locator('.p-select-overlay').first()
await expect(overlay).toBeVisible({ timeout: 5000 }) await expect(overlay).toBeVisible()
const isInViewport = await overlay.evaluate((el) => { await expect
const rect = el.getBoundingClientRect() .poll(() =>
return ( overlay.evaluate((el) => {
rect.top >= 0 && const rect = el.getBoundingClientRect()
rect.left >= 0 && return (
rect.bottom <= window.innerHeight && rect.top >= 0 &&
rect.right <= window.innerWidth rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
) )
}) .toBe(true)
expect(isInViewport).toBe(true)
const isClipped = await overlay.evaluate(isClippedByAnyAncestor) await expect
expect(isClipped).toBe(false) .poll(() => overlay.evaluate(isClippedByAnyAncestor))
.toBe(false)
}) })
test('FormDropdown popup is not clipped in app mode panel', async ({ test('FormDropdown popup is not clipped in app mode panel', async ({
@@ -119,9 +121,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
] ]
await comfyPage.appMode.enterAppModeWithInputs(inputs) await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({ await expect(comfyPage.appMode.linearWidgets).toBeVisible()
timeout: 5000
})
// Scroll to bottom so the image widget is at the clipping edge // Scroll to bottom so the image widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets const widgetList = comfyPage.appMode.linearWidgets
@@ -140,20 +140,24 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
// The unstyled PrimeVue Popover renders with role="dialog". // The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs"). // Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.appMode.imagePickerPopover const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible({ timeout: 5000 }) await expect(popover).toBeVisible()
const isInViewport = await popover.evaluate((el) => { await expect
const rect = el.getBoundingClientRect() .poll(() =>
return ( popover.evaluate((el) => {
rect.top >= 0 && const rect = el.getBoundingClientRect()
rect.left >= 0 && return (
rect.bottom <= window.innerHeight && rect.top >= 0 &&
rect.right <= window.innerWidth rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
) )
}) .toBe(true)
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor) await expect
expect(isClipped).toBe(false) .poll(() => popover.evaluate(isClippedByAnyAncestor))
.toBe(false)
}) })
}) })

View File

@@ -1,7 +1,7 @@
import { import {
comfyPageFixture as test, comfyPageFixture as test,
comfyExpect as expect comfyExpect as expect
} from '../fixtures/ComfyPage' } from '@e2e/fixtures/ComfyPage'
test.describe('App mode welcome states', { tag: '@ui' }, () => { test.describe('App mode welcome states', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
@@ -16,7 +16,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible() await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible() await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible() await expect(comfyPage.appMode.buildAppButton).toBeHidden()
}) })
test('Build app button is visible when no outputs selected', async ({ test('Build app button is visible when no outputs selected', async ({
@@ -26,7 +26,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible() await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeVisible() await expect(comfyPage.appMode.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible() await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
}) })
test('Empty workflow and build app are hidden when app has outputs', async ({ test('Empty workflow and build app are hidden when app has outputs', async ({
@@ -35,8 +35,8 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']]) await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible() await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible() await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible() await expect(comfyPage.appMode.buildAppButton).toBeHidden()
}) })
test('Back to workflow returns to graph mode', async ({ comfyPage }) => { test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
@@ -46,7 +46,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await comfyPage.appMode.backToWorkflowButton.click() await comfyPage.appMode.backToWorkflowButton.click()
await expect(comfyPage.canvas).toBeVisible() await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).not.toBeVisible() await expect(comfyPage.appMode.welcome).toBeHidden()
}) })
test('Load template opens template selector', async ({ comfyPage }) => { test('Load template opens template selector', async ({ comfyPage }) => {

View File

@@ -26,7 +26,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await appMode.steps.goToInputs() await appMode.steps.goToInputs()
const menu = appMode.select.getInputItemMenu('seed') const menu = appMode.select.getInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 }) await expect(menu).toBeVisible()
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed') await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
// Verify in app mode after save/reload // Verify in app mode after save/reload
@@ -34,7 +34,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const workflowName = `${new Date().getTime()} builder-input-menu` const workflowName = `${new Date().getTime()} builder-input-menu`
await saveAndReopenInAppMode(comfyPage, workflowName) await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
await expect( await expect(
appMode.linearWidgets.getByText('Builder Input Seed') appMode.linearWidgets.getByText('Builder Input Seed')
).toBeVisible() ).toBeVisible()
@@ -54,7 +54,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const workflowName = `${new Date().getTime()} builder-input-dblclick` const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName) await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible() await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
}) })
@@ -65,7 +65,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await appMode.steps.goToPreview() await appMode.steps.goToPreview()
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph') const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 }) await expect(menu).toBeVisible()
await appMode.select.renameWidget(menu, 'Preview Seed') await appMode.select.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload // Verify in app mode after save/reload
@@ -73,7 +73,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const workflowName = `${new Date().getTime()} builder-preview` const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName) await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible() await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
}) })
@@ -85,7 +85,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await appMode.footer.exitBuilder() await appMode.footer.exitBuilder()
await appMode.toggleAppMode() await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
const menu = appMode.getAppModeWidgetMenu('seed') const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.select.renameWidget(menu, 'App Mode Seed') await appMode.select.renameWidget(menu, 'App Mode Seed')
@@ -97,7 +97,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const workflowName = `${new Date().getTime()} app-mode` const workflowName = `${new Date().getTime()} app-mode`
await saveAndReopenInAppMode(comfyPage, workflowName) await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible() await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
}) })
}) })

View File

@@ -63,7 +63,7 @@ test.describe('App mode widget values in prompt', { tag: '@ui' }, () => {
({ nodeId, widgetName }) => [nodeId, widgetName] ({ nodeId, widgetName }) => [nodeId, widgetName]
) )
await appMode.enterAppModeWithInputs(inputs) await appMode.enterAppModeWithInputs(inputs)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
for (const { nodeId, widgetName, type, fill } of WIDGET_TEST_DATA) { for (const { nodeId, widgetName, type, fill } of WIDGET_TEST_DATA) {
const key = `${nodeId}:${widgetName}` const key = `${nodeId}:${widgetName}`

View File

@@ -11,7 +11,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
test('should open bottom panel via toggle button', async ({ comfyPage }) => { test('should open bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible() await expect(bottomPanel.root).toBeHidden()
await bottomPanel.toggleButton.click() await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible() await expect(bottomPanel.root).toBeVisible()
}) })
@@ -35,7 +35,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
await expect(bottomPanel.root).toBeVisible() await expect(bottomPanel.root).toBeVisible()
await bottomPanel.toggleButton.click() await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).not.toBeVisible() await expect(bottomPanel.root).toBeHidden()
}) })
test('should switch between shortcuts and terminal panels', async ({ test('should switch between shortcuts and terminal panels', async ({
@@ -55,7 +55,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
await expect(logsTab).toBeVisible() await expect(logsTab).toBeVisible()
await expect( await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]') comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).not.toBeVisible() ).toBeHidden()
}) })
test('should persist Logs tab content in bottom panel', async ({ test('should persist Logs tab content in bottom panel', async ({

View File

@@ -10,11 +10,11 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => { test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible() await expect(bottomPanel.root).toBeHidden()
await bottomPanel.keyboardShortcutsButton.click() await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible() await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click() await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible() await expect(bottomPanel.root).toBeHidden()
}) })
test('should display essentials shortcuts tab', async ({ comfyPage }) => { test('should display essentials shortcuts tab', async ({ comfyPage }) => {
@@ -103,14 +103,15 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
const keyBadges = bottomPanel.shortcuts.keyBadges const keyBadges = bottomPanel.shortcuts.keyBadges
await keyBadges.first().waitFor({ state: 'visible' }) await keyBadges.first().waitFor({ state: 'visible' })
const count = await keyBadges.count() await expect.poll(() => keyBadges.count()).toBeGreaterThanOrEqual(1)
expect(count).toBeGreaterThanOrEqual(1)
const badgeText = await keyBadges.allTextContents() await expect
const hasModifiers = badgeText.some((text) => .poll(() => keyBadges.allTextContents())
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text) .toEqual(
) expect.arrayContaining([
expect(hasModifiers).toBeTruthy() expect.stringMatching(/^(Ctrl|Cmd|Shift|Alt)$/)
])
)
}) })
test('should maintain panel state when switching between panels', async ({ test('should maintain panel state when switching between panels', async ({
@@ -181,7 +182,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(bottomPanel.root).toBeVisible() await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click() await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible() await expect(bottomPanel.root).toBeHidden()
}) })
test('should display shortcuts in organized columns', async ({ test('should display shortcuts in organized columns', async ({
@@ -191,13 +192,10 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await bottomPanel.keyboardShortcutsButton.click() await bottomPanel.keyboardShortcutsButton.click()
await expect( await expect(comfyPage.page.getByTestId('shortcuts-columns')).toBeVisible()
comfyPage.page.locator('[data-testid="shortcuts-columns"]')
).toBeVisible()
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
const titleCount = await subcategoryTitles.count() await expect.poll(() => subcategoryTitles.count()).toBeGreaterThanOrEqual(2)
expect(titleCount).toBeGreaterThanOrEqual(2)
}) })
test('should open shortcuts panel with Ctrl+Shift+K', async ({ test('should open shortcuts panel with Ctrl+Shift+K', async ({
@@ -205,7 +203,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
}) => { }) => {
const { bottomPanel } = comfyPage const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible() await expect(bottomPanel.root).toBeHidden()
await comfyPage.page.keyboard.press('Control+Shift+KeyK') await comfyPage.page.keyboard.press('Control+Shift+KeyK')

View File

@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper' import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import { import {
builderSaveAs, builderSaveAs,
createAndSaveApp,
openWorkflowFromSidebar, openWorkflowFromSidebar,
setupBuilder setupBuilder
} from '@e2e/helpers/builderTestUtils' } from '@e2e/helpers/builderTestUtils'
@@ -21,7 +22,7 @@ async function saveCloseAndReopenAsApp(
await appMode.steps.goToPreview() await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName) await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click() await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame() await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder() await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName) await openWorkflowFromSidebar(comfyPage, workflowName)
@@ -122,7 +123,7 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
const workflowName = `${Date.now()} reorder-preview` const workflowName = `${Date.now()} reorder-preview`
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName) await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.select.previewWidgetLabels).toHaveText([ await expect(appMode.select.previewWidgetLabels).toHaveText([
'steps', 'steps',
'cfg', 'cfg',
@@ -147,11 +148,58 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
const workflowName = `${Date.now()} reorder-persist` const workflowName = `${Date.now()} reorder-persist`
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName) await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 }) await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.select.previewWidgetLabels).toHaveText([ await expect(appMode.select.previewWidgetLabels).toHaveText([
'steps', 'steps',
'cfg', 'cfg',
'seed' 'seed'
]) ])
}) })
test('Reordering inputs in one app does not corrupt another app', async ({
comfyPage
}) => {
const { appMode } = comfyPage
const suffix = String(Date.now())
const app1Name = `app1-${suffix}`
const app2Name = `app2-${suffix}`
const app2Widgets = ['seed', 'steps']
// Create and save app1 with [seed, steps, cfg]
await createAndSaveApp(comfyPage, app1Name, WIDGETS)
await appMode.footer.exitBuilder()
// Create app2 in a new tab so both apps are open simultaneously
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await createAndSaveApp(comfyPage, app2Name, app2Widgets)
await appMode.footer.exitBuilder()
// Switch to app1 tab and enter builder
await comfyPage.menu.topbar.getWorkflowTab(app1Name).click()
await appMode.enterBuilder()
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
// Reorder app1 inputs: drag 'seed' from first to last
await appMode.select.dragInputItem(0, 2)
const app1Reordered = ['steps', 'cfg', 'seed']
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
// Switch to app2 tab and enter builder
await appMode.footer.exitBuilder()
await comfyPage.menu.topbar.getWorkflowTab(app2Name).click()
await appMode.enterBuilder()
await appMode.steps.goToInputs()
// Verify app2 inputs are not corrupted — still [seed, steps]
await expect(appMode.select.inputItemTitles).toHaveText(app2Widgets)
// Switch back to app1 and verify reorder persisted
await appMode.footer.exitBuilder()
await comfyPage.menu.topbar.getWorkflowTab(app1Name).click()
await appMode.enterBuilder()
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
})
}) })

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,18 +65,28 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
// Add a new CheckpointLoaderSimple — should use first cloud asset, // Add a new CheckpointLoaderSimple — should use first cloud asset,
// not the server's object_info default. // not the server's object_info default.
const widgetValue = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
window.app!.graph.add(node!)
const widget = node!.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
})
// Production resolves via getAssetFilename (user_metadata.filename → // Production resolves via getAssetFilename (user_metadata.filename →
// metadata.filename → asset.name). Test fixtures have no metadata // metadata.filename → asset.name). Test fixtures have no metadata
// filename, so asset.name is the resolved value. // filename, so asset.name is the resolved value.
expect(widgetValue).toBe(CLOUD_ASSETS[0].name) const nodeId = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
window.app!.graph.add(node!)
return node!.id
})
await expect
.poll(
async () => {
return await comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
}, nodeId)
},
{ timeout: 10_000 }
)
.toBe(CLOUD_ASSETS[0].name)
}) })
}) })

View File

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

View File

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

View File

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

View File

@@ -149,11 +149,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await comfyPage.canvas.click({ position: { x: 50, y: 500 } }) await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.nextFrame() await comfyPage.nextFrame()
await comfyPage.clipboard.paste() await comfyPage.clipboard.paste()
await expect await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5_000
})
.toBe(3)
// Step 2: Paste image onto selected LoadImage node // Step 2: Paste image onto selected LoadImage node
const loadImageNodes = const loadImageNodes =
@@ -171,13 +167,10 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await uploadPromise await uploadPromise
await expect await expect
.poll( .poll(async () => {
async () => { const fileWidget = await loadImageNodes[0].getWidget(0)
const fileWidget = await loadImageNodes[0].getWidget(0) return fileWidget.getValue()
return fileWidget.getValue() })
},
{ timeout: 5_000 }
)
.toContain('image32x32') .toContain('image32x32')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3) await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
@@ -194,11 +187,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
) )
await uploadPromise2 await uploadPromise2
await expect await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(4)
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5_000
})
.toBe(4)
const allLoadImageNodes = const allLoadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage') await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
expect(allLoadImageNodes).toHaveLength(2) expect(allLoadImageNodes).toHaveLength(2)

View File

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

View File

@@ -38,15 +38,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
`.${tabId}-tab-button.side-bar-button-selected` `.${tabId}-tab-button.side-bar-button-selected`
) )
await expect(selectedButton).not.toBeVisible() await expect(selectedButton).toBeHidden()
await comfyPage.canvas.press(key) await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible() await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key) await comfyPage.canvas.press(key)
await comfyPage.nextFrame() await expect(selectedButton).toBeHidden()
await expect(selectedButton).not.toBeVisible()
}) })
} }
}) })
@@ -58,8 +56,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.press('Alt+Equal') await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame() await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale() await expect
expect(newScale).toBeGreaterThan(initialScale) .poll(() => comfyPage.canvasOps.getScale())
.toBeGreaterThan(initialScale)
}) })
test("'Alt+-' zooms out", async ({ comfyPage }) => { test("'Alt+-' zooms out", async ({ comfyPage }) => {
@@ -68,15 +67,17 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.press('Alt+Minus') await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame() await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale() await expect
expect(newScale).toBeLessThan(initialScale) .poll(() => comfyPage.canvasOps.getScale())
.toBeLessThan(initialScale)
}) })
test("'.' fits view to nodes", async ({ comfyPage }) => { test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes // Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1) await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale() await expect
expect(scaleBefore).toBeCloseTo(0.1, 1) .poll(() => comfyPage.canvasOps.getScale())
.toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container // Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } }) await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
@@ -85,29 +86,30 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.press('Period') await comfyPage.canvas.press('Period')
await comfyPage.nextFrame() await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale() await expect
expect(scaleAfter).toBeGreaterThan(scaleBefore) .poll(() => comfyPage.canvasOps.getScale())
.toBeGreaterThan(0.1)
}) })
test("'h' locks canvas", async ({ comfyPage }) => { test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false) await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH') await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true) await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
}) })
test("'v' unlocks canvas", async ({ comfyPage }) => { test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first // Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock') await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true) await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV') await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false) await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
}) })
}) })
@@ -122,15 +124,15 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await node.click('title') await node.click('title')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false) await expect.poll(() => node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC') await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true) await expect.poll(() => node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC') await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false) await expect.poll(() => node.isCollapsed()).toBe(false)
}) })
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => { test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
@@ -147,16 +149,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id) }, node.id)
expect(await getMode()).toBe(0) await expect.poll(() => getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM') await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame() await comfyPage.nextFrame()
// NEVER (2) = muted // NEVER (2) = muted
expect(await getMode()).toBe(2) await expect.poll(() => getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM') await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await getMode()).toBe(0) await expect.poll(() => getMode()).toBe(0)
}) })
}) })
@@ -170,12 +172,10 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// Toggle off with Alt+m // Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM') await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame() await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again // Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM') await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible() await expect(comfyPage.appMode.linearWidgets).toBeVisible()
}) })
@@ -189,26 +189,22 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect(minimap).toBeVisible() await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM') await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame() await expect(minimap).toBeHidden()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM') await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible() await expect(minimap).toBeVisible()
}) })
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => { test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible() await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.page.keyboard.press('Control+Backquote') await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible() await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote') await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame() await expect(comfyPage.bottomPanel.root).toBeHidden()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
}) })
}) })
@@ -254,7 +250,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The Save As dialog should appear (p-dialog overlay) // The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask') const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 }) await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog // Dismiss the dialog
await comfyPage.page.keyboard.press('Escape') await comfyPage.page.keyboard.press('Escape')
@@ -278,7 +274,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.page.keyboard.press('Control+o') await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true) await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
.toBe(true)
}) })
}) })
@@ -286,8 +284,14 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({ test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage comfyPage
}) => { }) => {
await expect
.poll(
() => comfyPage.nodeOps.getGraphNodesCount(),
'Default workflow should have multiple nodes'
)
.toBeGreaterThan(1)
const initialCount = await comfyPage.nodeOps.getGraphNodesCount() const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes // Select all nodes
await comfyPage.canvas.press('Control+a') await comfyPage.canvas.press('Control+a')
@@ -299,9 +303,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// After conversion, node count should decrease // After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node) // (multiple nodes replaced by single subgraph node)
await expect await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { .poll(() => comfyPage.nodeOps.getGraphNodesCount())
timeout: 5000
})
.toBeLessThan(initialCount) .toBeLessThan(initialCount)
}) })

View File

@@ -11,55 +11,49 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Settings', () => { test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => { test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,') await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator( const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible() await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main') const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible() await expect(contentArea).toBeVisible()
const isUsableHeight = await contentArea.evaluate( await expect
(el) => el.clientHeight > 30 .poll(() => contentArea.evaluate((el) => el.clientHeight))
) .toBeGreaterThan(30)
expect(isUsableHeight).toBeTruthy()
}) })
test('Can open settings with hotkey', async ({ comfyPage }) => { test('Can open settings with hotkey', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('ControlOrMeta') await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',') await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta') await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator( const settingsLocator = comfyPage.page.getByTestId('settings-dialog')
'[data-testid="settings-dialog"]'
)
await expect(settingsLocator).toBeVisible() await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape') await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible() await expect(settingsLocator).toBeHidden()
}) })
test('Can change canvas zoom speed setting', async ({ comfyPage }) => { test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const maxSpeed = 2.5 const maxSpeed = 2.5
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed) await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await test.step('Setting should persist', async () => { await test.step('Setting should persist', async () => {
expect(await comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed')).toBe( await expect
maxSpeed .poll(() => comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed'))
) .toBe(maxSpeed)
}) })
}) })
test('Should persist keybinding setting', async ({ comfyPage }) => { test('Should persist keybinding setting', async ({ comfyPage }) => {
// Open the settings dialog // Open the settings dialog
await comfyPage.page.keyboard.press('Control+,') await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
// Open the keybinding tab // Open the keybinding tab
const settingsDialog = comfyPage.page.locator( const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
'[data-testid="settings-dialog"]' await expect(settingsDialog).toBeVisible()
)
await settingsDialog await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' }) .locator('nav [role="button"]', { hasText: 'Keybinding' })
.click() .click()
await comfyPage.page.waitForSelector( await expect(
'[placeholder="Search Keybindings..."]' comfyPage.page.getByPlaceholder('Search Keybindings...')
) ).toBeVisible()
// Focus the 'New Blank Workflow' row // Focus the 'New Blank Workflow' row
const newBlankWorkflowRow = comfyPage.page.locator('tr', { const newBlankWorkflowRow = comfyPage.page.locator('tr', {
@@ -156,6 +150,6 @@ test.describe('Signin dialog', () => {
await input.press('Control+v') await input.press('Control+v')
await expect(input).toHaveValue('test_password') await expect(input).toHaveValue('test_password')
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum) await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
}) })
}) })

View File

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

View File

@@ -60,7 +60,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
}) })
await dialog.getByRole('button', { name: 'Cancel' }).click() await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible() await expect(dialog).toBeHidden()
expect(clearCalled).toBe(false) expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history') await comfyPage.page.unroute('**/api/history')
@@ -83,7 +83,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
}) })
await dialog.getByLabel('Close').click() await dialog.getByLabel('Close').click()
await expect(dialog).not.toBeVisible() await expect(dialog).toBeHidden()
expect(clearCalled).toBe(false) expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history') await comfyPage.page.unroute('**/api/history')
@@ -106,7 +106,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
const request = await clearPromise const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true }) expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog).not.toBeVisible() await expect(dialog).toBeHidden()
}) })
test('Dialog state resets after close and reopen', async ({ comfyPage }) => { test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
@@ -114,7 +114,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
const dialog = comfyPage.confirmDialog.root const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click() await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible() await expect(dialog).toBeHidden()
await comfyPage.queuePanel.openClearHistoryDialog() await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => { test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline') await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input') const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).not.toBeVisible() await expect(textareaWidget).toBeHidden()
}) })
test('Multiline textarea correctly collapses', async ({ comfyPage }) => { test('Multiline textarea correctly collapses', async ({ comfyPage }) => {
@@ -25,8 +25,8 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
for (const node of nodes) { for (const node of nodes) {
await node.click('collapse') await node.click('collapse')
} }
await expect(firstMultiline).not.toBeVisible() await expect(firstMultiline).toBeHidden()
await expect(lastMultiline).not.toBeVisible() await expect(lastMultiline).toBeHidden()
}) })
test( test(
@@ -35,14 +35,14 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
async ({ comfyPage }) => { async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode') await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame() await expect(comfyPage.menu.sideToolbar).toBeHidden()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png') await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
} }
) )
// No DOM widget should be created by creation of interim LGraphNode objects. // No DOM widget should be created by creation of interim LGraphNode objects.
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => { test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {
const initialCount = await comfyPage.getDOMWidgetCount() const initialCount = await comfyPage.domWidgets.count()
// TextEncodeNode1 // TextEncodeNode1
await comfyPage.page.mouse.move(618, 191) await comfyPage.page.mouse.move(618, 191)
@@ -52,7 +52,6 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
await comfyPage.page.mouse.up() await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt') await comfyPage.page.keyboard.up('Alt')
const finalCount = await comfyPage.getDOMWidgetCount() await expect(comfyPage.domWidgets).toHaveCount(initialCount + 1)
expect(finalCount).toBe(initialCount + 1)
}) })
}) })

View File

@@ -74,7 +74,7 @@ test.describe('Error dialog', () => {
}) => { }) => {
const errorDialog = await triggerConfigureError(comfyPage) const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible() await expect(errorDialog).toBeVisible()
await expect(errorDialog.locator('pre')).not.toBeVisible() await expect(errorDialog.locator('pre')).toBeHidden()
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click() await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
@@ -83,7 +83,7 @@ test.describe('Error dialog', () => {
await expect(reportPre).toHaveText(/\S/) await expect(reportPre).toHaveText(/\S/)
await expect( await expect(
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport) errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
).not.toBeVisible() ).toBeHidden()
}) })
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({ test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({
@@ -100,8 +100,9 @@ test.describe('Error dialog', () => {
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click() await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
const reportText = await errorDialog.locator('pre').textContent() const reportText = await errorDialog.locator('pre').textContent()
const copiedText = await getClipboardText(comfyPage.page) await expect
expect(copiedText).toBe(reportText) .poll(async () => await getClipboardText(comfyPage.page))
.toBe(reportText)
}) })
test('Should open GitHub issues search when "Find Issues" is clicked', async ({ test('Should open GitHub issues search when "Find Issues" is clicked', async ({

View File

@@ -47,11 +47,16 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
test('Should display "Show missing models" button for missing model errors', async ({ test('Should display "Show missing models" button for missing model errors', async ({
comfyPage comfyPage
}) => { }) => {
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => { await expect
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`) .poll(() =>
return response.ok comfyPage.page.evaluate(async (url: string) => {
}, comfyPage.url) const response = await fetch(
expect(cleanupOk).toBeTruthy() `${url}/api/devtools/cleanup_fake_model`
)
return response.ok
}, comfyPage.url)
)
.toBeTruthy()
await comfyPage.workflow.loadWorkflow('missing/missing_models') await comfyPage.workflow.loadWorkflow('missing/missing_models')
@@ -95,7 +100,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await errorOverlay await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss) .getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click() .click()
await expect(errorOverlay).not.toBeVisible() await expect(errorOverlay).toBeHidden()
await comfyPage.canvas.click() await comfyPage.canvas.click()
await comfyPage.nextFrame() await comfyPage.nextFrame()
@@ -107,10 +112,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame() await comfyPage.nextFrame()
await comfyPage.keyboard.undo() await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 }) await expect(errorOverlay).toBeHidden()
await comfyPage.keyboard.redo() await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 }) await expect(errorOverlay).toBeHidden()
}) })
}) })
@@ -151,6 +156,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click() await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible() await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
}) })
@@ -162,7 +168,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click() await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible() await expect(overlay).toBeHidden()
}) })
test('"Dismiss" closes overlay without opening panel', async ({ test('"Dismiss" closes overlay without opening panel', async ({
@@ -175,10 +181,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click() await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible() await expect(overlay).toBeHidden()
await expect( await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
}) })
test('Close button (X) dismisses overlay', async ({ comfyPage }) => { test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
@@ -189,7 +193,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByRole('button', { name: /close/i }).click() await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible() await expect(overlay).toBeHidden()
}) })
}) })
}) })

View File

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

View File

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

View File

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

View File

@@ -14,12 +14,12 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true) await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeHidden()
}) })
test('Focus mode restores UI chrome', async ({ comfyPage }) => { test('Focus mode restores UI chrome', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true) await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeHidden()
await comfyPage.setFocusMode(false) await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeVisible()
@@ -29,11 +29,9 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await expect(comfyPage.menu.sideToolbar).toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode') await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame() await expect(comfyPage.menu.sideToolbar).toBeHidden()
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode') await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeVisible()
}) })
@@ -43,7 +41,7 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true) await comfyPage.setFocusMode(true)
await expect(topMenu).not.toBeVisible() await expect(topMenu).toBeHidden()
}) })
test('Canvas remains visible in focus mode', async ({ comfyPage }) => { test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
@@ -54,12 +52,12 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => { test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true) await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeHidden()
await comfyPage.setFocusMode(false) await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.setFocusMode(true) await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible() await expect(comfyPage.menu.sideToolbar).toBeHidden()
}) })
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Image Compare', () => { test.describe('Image Compare', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget') await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
@@ -21,7 +22,12 @@ test.describe('Image Compare', () => {
async function setImageCompareValue( async function setImageCompareValue(
comfyPage: ComfyPage, comfyPage: ComfyPage,
value: { beforeImages: string[]; afterImages: string[] } value: {
beforeImages: string[]
afterImages: string[]
beforeAlt?: string
afterAlt?: string
}
) { ) {
await comfyPage.page.evaluate( await comfyPage.page.evaluate(
({ value }) => { ({ value }) => {
@@ -37,6 +43,48 @@ test.describe('Image Compare', () => {
await comfyPage.nextFrame() await comfyPage.nextFrame()
} }
async function moveToPercentage(
page: Page,
containerLocator: Locator,
percentage: number
) {
const box = await containerLocator.boundingBox()
if (!box) throw new Error('Container not found')
await page.mouse.move(
box.x + box.width * (percentage / 100),
box.y + box.height / 2
)
}
async function waitForImagesLoaded(node: Locator) {
await expect
.poll(() =>
node.evaluate((el) => {
const imgs = el.querySelectorAll('img')
return (
imgs.length > 0 &&
Array.from(imgs).every(
(img) => img.complete && img.naturalWidth > 0
)
)
})
)
.toBe(true)
}
async function getClipPathInsetRightPercent(imgLocator: Locator) {
return imgLocator.evaluate((el) => {
// Accessing raw style avoids cross-browser getComputedStyle normalization issues
// Format is uniformly "inset(0 60% 0 0)" per Vue runtime inline style bindings
const parts = (el as HTMLElement).style.clipPath.split(' ')
return parts.length > 1 ? parseFloat(parts[1]) : -1
})
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
test( test(
'Shows empty state when no images are set', 'Shows empty state when no images are set',
{ tag: '@smoke' }, { tag: '@smoke' },
@@ -46,10 +94,14 @@ test.describe('Image Compare', () => {
await expect(node).toContainText('No images to compare') await expect(node).toContainText('No images to compare')
await expect(node.locator('img')).toHaveCount(0) await expect(node.locator('img')).toHaveCount(0)
await expect(node.locator('[role="presentation"]')).toHaveCount(0) await expect(node.getByRole('presentation')).toHaveCount(0)
} }
) )
// ---------------------------------------------------------------------------
// Slider defaults
// ---------------------------------------------------------------------------
test( test(
'Slider defaults to 50% with both images set', 'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] }, { tag: ['@smoke', '@screenshot'] },
@@ -67,15 +119,444 @@ test.describe('Image Compare', () => {
await expect(beforeImg).toBeVisible() await expect(beforeImg).toBeVisible()
await expect(afterImg).toBeVisible() await expect(afterImg).toBeVisible()
const handle = node.locator('[role="presentation"]') const handle = node.getByRole('presentation')
await expect(handle).toBeVisible() await expect(handle).toBeVisible()
expect( expect(
await handle.evaluate((el) => (el as HTMLElement).style.left) await handle.evaluate((el) => (el as HTMLElement).style.left),
'Slider should default to 50% before screenshot'
).toBe('50%') ).toBe('50%')
await expect(beforeImg).toHaveCSS('clip-path', /50%/) await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeCloseTo(50, 0)
await waitForImagesLoaded(node)
await comfyPage.page.mouse.move(0, 0)
await expect(node).toHaveScreenshot('image-compare-default-50.png') await expect(node).toHaveScreenshot('image-compare-default-50.png')
} }
) )
// ---------------------------------------------------------------------------
// Slider interaction
// ---------------------------------------------------------------------------
test(
'Mouse hover moves slider position',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
await expect(afterImg).toBeVisible()
// Left edge: sliderPosition ≈ 5 → clip-path inset right ≈ 95%
await moveToPercentage(comfyPage.page, afterImg, 5)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeGreaterThan(90)
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeLessThan(10)
// Right edge: sliderPosition ≈ 95 → clip-path inset right ≈ 5%
await moveToPercentage(comfyPage.page, afterImg, 95)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeLessThan(10)
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeGreaterThan(90)
}
)
test('Slider preserves last position when mouse leaves widget', async ({
comfyPage
}) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
await expect(afterImg).toBeVisible()
await moveToPercentage(comfyPage.page, afterImg, 30)
// Wait for Vue to commit the slider update
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeCloseTo(30, 0)
const positionWhileInside = parseFloat(
await handle.evaluate((el) => (el as HTMLElement).style.left)
)
await comfyPage.page.mouse.move(0, 0)
// Position must not reset to default 50%
await expect
.poll(() =>
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
)
.toBeCloseTo(positionWhileInside, 0)
})
test('Slider clamps to 0% at left edge of container', async ({
comfyPage
}) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
await expect(afterImg).toBeVisible()
const box = await afterImg.boundingBox()
if (!box) throw new Error('Container not found')
// Move to the leftmost pixel (elementX = 0 → sliderPosition = 0)
await comfyPage.page.mouse.move(box.x, box.y + box.height / 2)
await expect
.poll(() => handle.evaluate((el) => (el as HTMLElement).style.left))
.toBe('0%')
})
// ---------------------------------------------------------------------------
// Single image modes
// ---------------------------------------------------------------------------
test('Only before image shows without slider when afterImages is empty', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('Before', '#c00')
await setImageCompareValue(comfyPage, {
beforeImages: [url],
afterImages: []
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(1)
await expect(node.getByRole('presentation')).toBeHidden()
})
test('Only after image shows without slider when beforeImages is empty', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [],
afterImages: [url]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(1)
await expect(node.getByRole('presentation')).toBeHidden()
})
// ---------------------------------------------------------------------------
// Batch navigation
// ---------------------------------------------------------------------------
test(
'Batch navigation appears when before side has multiple images',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
const afterUrl = createTestImageDataUrl('B1', '#888')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
await expect(node.getByTestId('batch-nav')).toBeVisible()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
// after-batch renders only when afterBatchCount > 1
await expect(node.getByTestId('after-batch')).toBeHidden()
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
}
)
test('Batch navigation is hidden when both sides have single images', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('Image', '#c00')
await setImageCompareValue(comfyPage, {
beforeImages: [url],
afterImages: [url]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.getByTestId('batch-nav')).toBeHidden()
})
test(
'Navigate forward through before images',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [createTestImageDataUrl('B1', '#888')]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
await nextBtn.click()
await expect(counter).toHaveText('2 / 3')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
)
await expect(prevBtn).toBeEnabled()
await nextBtn.click()
await expect(counter).toHaveText('3 / 3')
await expect(nextBtn).toBeDisabled()
}
)
test('Navigate backward through before images', async ({ comfyPage }) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [createTestImageDataUrl('B1', '#888')]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
await nextBtn.click()
await nextBtn.click()
await expect(counter).toHaveText('3 / 3')
await prevBtn.click()
await expect(counter).toHaveText('2 / 3')
await expect(prevBtn).toBeEnabled()
await expect(nextBtn).toBeEnabled()
})
test('Before and after batch navigation are independent', async ({
comfyPage
}) => {
const url1 = createTestImageDataUrl('A1', '#c00')
const url2 = createTestImageDataUrl('A2', '#0c0')
const url3 = createTestImageDataUrl('A3', '#00c')
const urlA = createTestImageDataUrl('B1', '#880')
const urlB = createTestImageDataUrl('B2', '#008')
await setImageCompareValue(comfyPage, {
beforeImages: [url1, url2, url3],
afterImages: [urlA, urlB]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
await beforeBatch.getByTestId('batch-next').click()
await afterBatch.getByTestId('batch-next').click()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
)
await expect(node.locator('img[alt="After image"]')).toHaveAttribute(
'src',
urlB
)
})
// ---------------------------------------------------------------------------
// Visual regression screenshots
// ---------------------------------------------------------------------------
for (const { pct, expectedClipMin, expectedClipMax } of [
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
]) {
test(
`Screenshot at ${pct}% slider position`,
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
await waitForImagesLoaded(node)
await moveToPercentage(comfyPage.page, afterImg, pct)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeGreaterThan(expectedClipMin)
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeLessThan(expectedClipMax)
await expect(node).toHaveScreenshot(`image-compare-slider-${pct}.png`)
}
)
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
test('Widget remains stable with broken image URLs', async ({
comfyPage
}) => {
await setImageCompareValue(comfyPage, {
beforeImages: ['https://example.invalid/broken.png'],
afterImages: ['https://example.invalid/broken2.png']
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(2)
await expect(node.getByRole('presentation')).toBeVisible()
await expect
.poll(() =>
node.evaluate((el) => {
const imgs = el.querySelectorAll('img')
let errors = 0
imgs.forEach((img) => {
if (img.complete && img.naturalWidth === 0 && img.src) errors++
})
return errors
})
)
.toBe(2)
})
test('Rapid value updates show latest images and reset batch index', async ({
comfyPage
}) => {
const redUrl = createTestImageDataUrl('Red', '#c00')
const green1Url = createTestImageDataUrl('G1', '#0c0')
const green2Url = createTestImageDataUrl('G2', '#090')
const blueUrl = createTestImageDataUrl('Blue', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [redUrl, green1Url],
afterImages: [blueUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByTestId('before-batch').getByTestId('batch-next').click()
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('2 / 2')
await setImageCompareValue(comfyPage, {
beforeImages: [green1Url, green2Url],
afterImages: [blueUrl]
})
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
green1Url
)
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('1 / 2')
})
test('Legacy string value shows single image without slider', async ({
comfyPage
}) => {
const url = createTestImageDataUrl('Legacy', '#c00')
await comfyPage.page.evaluate(
({ url }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = url
widget.callback?.(url)
}
},
{ url }
)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toHaveCount(1)
await expect(node.getByRole('presentation')).toBeHidden()
})
test('Custom beforeAlt and afterAlt are used as img alt text', async ({
comfyPage
}) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl],
beforeAlt: 'Custom before',
afterAlt: 'Custom after'
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img[alt="Custom before"]')).toBeVisible()
await expect(node.locator('img[alt="Custom after"]')).toBeVisible()
})
test('Large batch sizes show correct counter', async ({ comfyPage }) => {
const images = Array.from({ length: 20 }, (_, i) =>
createTestImageDataUrl(String(i + 1), '#c00')
)
await setImageCompareValue(comfyPage, {
beforeImages: images,
afterImages: images
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('1 / 20')
await expect(
node.getByTestId('after-batch').getByTestId('batch-counter')
).toHaveText('1 / 20')
})
}) })

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

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