mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-30 01:05:46 +00:00
Compare commits
31 Commits
bl/com-304
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc6f8c1ce | ||
|
|
1ffd92f910 | ||
|
|
81d3ef22b0 | ||
|
|
2d99fb446c | ||
|
|
dee236cd60 | ||
|
|
b12b20b5ab | ||
|
|
04f90b7a05 | ||
|
|
1e7c8d9889 | ||
|
|
367f810702 | ||
|
|
798f6de4a9 | ||
|
|
752641cc67 | ||
|
|
af0f7cb945 | ||
|
|
ac0175aa6a | ||
|
|
1e1b3884c5 | ||
|
|
bce7a168de | ||
|
|
e7c2cd04f4 | ||
|
|
391a6db056 | ||
|
|
4d4dca2a46 | ||
|
|
ba9f3481fb | ||
|
|
7cbd61aaea | ||
|
|
b09562a1bf | ||
|
|
cc8ef09d28 | ||
|
|
64917e5b6c | ||
|
|
0e7cab96b7 | ||
|
|
e0d16b7ee9 | ||
|
|
8eb1525171 | ||
|
|
48219109d3 | ||
|
|
81e6282599 | ||
|
|
b8480f889e | ||
|
|
b49ea9fabd | ||
|
|
8da4640a76 |
74
.agents/checks/playwright-e2e.md
Normal file
74
.agents/checks/playwright-e2e.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: playwright-e2e
|
||||
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
|
||||
severity-default: medium
|
||||
tools: [Read, Grep]
|
||||
---
|
||||
|
||||
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
|
||||
|
||||
Reference docs (read if you need full context):
|
||||
|
||||
- `browser_tests/README.md` — setup, patterns, screenshot workflow
|
||||
- `browser_tests/AGENTS.md` — directory structure, fixture overview
|
||||
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
|
||||
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
|
||||
|
||||
## Checks
|
||||
|
||||
### Flakiness Risks (Major)
|
||||
|
||||
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
|
||||
|
||||
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
|
||||
|
||||
3. **Keyboard actions without prior focus** — `page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
|
||||
|
||||
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
|
||||
|
||||
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
|
||||
|
||||
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
|
||||
|
||||
7. **Double-click without `{ delay }` option** — `dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
|
||||
|
||||
### Fixture & API Misuse (Medium)
|
||||
|
||||
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
|
||||
- `comfyPage.command.executeCommand()` for menu/command actions
|
||||
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
|
||||
- `comfyPage.canvasOps.resetView()` for view reset
|
||||
- `comfyPage.settings.setSetting()` for settings
|
||||
- Component page objects in `browser_tests/fixtures/components/`
|
||||
|
||||
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
|
||||
|
||||
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
|
||||
|
||||
### Convention Violations (Minor)
|
||||
|
||||
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
|
||||
|
||||
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
|
||||
|
||||
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
|
||||
|
||||
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
|
||||
|
||||
15. **Debug helpers left in committed code** — `debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
|
||||
|
||||
### Test Design (Nitpick)
|
||||
|
||||
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
|
||||
|
||||
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
|
||||
|
||||
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
|
||||
|
||||
## Rules
|
||||
|
||||
- Only review `.spec.ts` files and supporting code in `browser_tests/`
|
||||
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
|
||||
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
|
||||
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
|
||||
- Existing tests that predate conventions are acceptable to modify but not required to fix
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -13,8 +13,6 @@ runs:
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -17,8 +17,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -22,8 +22,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -20,8 +20,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -76,8 +74,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -99,7 +95,7 @@ jobs:
|
||||
if npx license-checker-rseidelsohn@4 \
|
||||
--production \
|
||||
--summary \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--clarificationsFile .github/license-clarifications.json \
|
||||
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
|
||||
echo ''
|
||||
|
||||
18
.github/workflows/ci-tests-e2e.yaml
vendored
18
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -33,6 +33,20 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-dist-cloud
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Sharded chromium tests
|
||||
playwright-tests-chromium-sharded:
|
||||
needs: setup
|
||||
@@ -97,14 +111,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Download built frontend
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
|
||||
path: dist/
|
||||
|
||||
- name: Start ComfyUI server
|
||||
|
||||
13
.github/workflows/ci-tests-unit.yaml
vendored
13
.github/workflows/ci-tests-unit.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
# Description: Unit and component testing with Vitest
|
||||
# Description: Unit and component testing with Vitest + coverage reporting
|
||||
name: 'CI: Tests Unit'
|
||||
|
||||
on:
|
||||
@@ -23,5 +23,12 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run Vitest tests
|
||||
run: pnpm test:unit
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
fail_ci_if_error: false
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -30,8 +30,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -85,8 +85,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -76,8 +76,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -203,8 +201,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
4
.github/workflows/release-draft-create.yaml
vendored
4
.github/workflows/release-draft-create.yaml
vendored
@@ -20,10 +20,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
2
.github/workflows/release-npm-types.yaml
vendored
@@ -76,8 +76,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
4
.github/workflows/release-pypi-dev.yaml
vendored
4
.github/workflows/release-pypi-dev.yaml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
2
.github/workflows/release-version-bump.yaml
vendored
@@ -144,8 +144,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -52,8 +52,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -30,8 +30,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -216,6 +216,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
|
||||
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
|
||||
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
|
||||
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
|
||||
|
||||
## External Resources
|
||||
|
||||
|
||||
38
CODEOWNERS
38
CODEOWNERS
@@ -41,12 +41,46 @@
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
@@ -12,12 +12,13 @@ browser_tests/
|
||||
│ ├── ComfyMouse.ts - Mouse interaction helper
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── components/ - Page object components
|
||||
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
|
||||
│ ├── components/ - Page object components (locators, user interactions)
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes
|
||||
│ ├── helpers/ - Focused helper classes (domain-specific actions)
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
│ │ ├── KeyboardHelper.ts
|
||||
@@ -25,11 +26,18 @@ browser_tests/
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Utility functions
|
||||
│ └── utils/ - Pure utility functions (no page dependency)
|
||||
├── helpers/ - Test-specific utilities
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
### Architectural Separation
|
||||
|
||||
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
|
||||
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
|
||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
||||
|
||||
## Polling Assertions
|
||||
|
||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"steps": 20,
|
||||
"cfg": 8.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0
|
||||
},
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"2": {
|
||||
"class_type": "NonExistentCustomNode_XYZ_12345",
|
||||
"inputs": {
|
||||
"input1": "test"
|
||||
},
|
||||
"_meta": { "title": "Missing Node" }
|
||||
},
|
||||
"3": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
},
|
||||
"_meta": { "title": "Empty Latent Image" }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import type {
|
||||
APIRequestContext,
|
||||
ExpectMatcherState,
|
||||
Locator,
|
||||
Page
|
||||
} from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { sleep } from './utils/timing'
|
||||
import { comfyExpect } from './utils/customMatchers'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
@@ -18,6 +15,7 @@ import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import { QueuePanel } from './components/QueuePanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
@@ -39,7 +37,7 @@ import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import { assetPath } from './utils/paths'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
@@ -123,8 +121,18 @@ type KeysOfType<T, Match> = {
|
||||
[K in keyof T]: T[K] extends Match ? K : never
|
||||
}[keyof T]
|
||||
|
||||
class MediaLightbox {
|
||||
readonly root: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
this.closeButton = this.root.getByLabel('Close')
|
||||
}
|
||||
}
|
||||
|
||||
class ConfirmDialog {
|
||||
private readonly root: Locator
|
||||
public readonly root: Locator
|
||||
public readonly delete: Locator
|
||||
public readonly overwrite: Locator
|
||||
public readonly reject: Locator
|
||||
@@ -184,6 +192,7 @@ export class ComfyPage {
|
||||
public readonly templates: ComfyTemplates
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly mediaLightbox: MediaLightbox
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly appMode: AppModeHelper
|
||||
public readonly subgraph: SubgraphHelper
|
||||
@@ -199,6 +208,7 @@ export class ComfyPage {
|
||||
public readonly featureFlags: FeatureFlagHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
@@ -231,6 +241,7 @@ export class ComfyPage {
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.mediaLightbox = new MediaLightbox(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.appMode = new AppModeHelper(this)
|
||||
this.subgraph = new SubgraphHelper(this)
|
||||
@@ -242,10 +253,11 @@ export class ComfyPage {
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.dragDrop = new DragDropHelper(page)
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
@@ -343,8 +355,9 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/** @deprecated Use standalone `assetPath` from `browser_tests/fixtures/utils/assetPath` directly. */
|
||||
public assetPath(fileName: string) {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
return assetPath(fileName)
|
||||
}
|
||||
|
||||
async goto() {
|
||||
@@ -358,7 +371,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return sleep(ms)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,49 +489,4 @@ export const comfyPageFixture = base.extend<{
|
||||
}
|
||||
})
|
||||
|
||||
const makeMatcher = function <T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
const value = await getValue(node)
|
||||
let assertion = expect(
|
||||
value,
|
||||
'Node is ' + (this.isNot ? '' : 'not ') + type
|
||||
)
|
||||
if (this.isNot) {
|
||||
assertion = assertion.not
|
||||
}
|
||||
await expect(async () => {
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
|
||||
await expect(async () => {
|
||||
expect(isFocused).toBe(!this.isNot)
|
||||
}).toPass(options)
|
||||
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
export { comfyExpect }
|
||||
|
||||
@@ -31,10 +31,14 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a Vue node 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.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
constructor(public readonly page: Page) {}
|
||||
readonly root: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
}
|
||||
|
||||
get header() {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
return this.root
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Add node filter condition' })
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
@@ -33,6 +34,20 @@ export class ContextMenu {
|
||||
return primeVueVisible || litegraphVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await expect
|
||||
.soft(this.page.getByRole('menuitem', { name: item }))
|
||||
.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
return this
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
||||
const count = await locator.count()
|
||||
|
||||
24
browser_tests/fixtures/components/QueuePanel.ts
Normal file
24
browser_tests/fixtures/components/QueuePanel.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
readonly moreOptionsButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
|
||||
this.moreOptionsButton = page.getByLabel(/More options/i).first()
|
||||
}
|
||||
|
||||
async openClearHistoryDialog() {
|
||||
await this.moreOptionsButton.click()
|
||||
|
||||
const clearHistoryAction = this.page.getByTestId(
|
||||
TestIds.queue.clearHistoryAction
|
||||
)
|
||||
await expect(clearHistoryAction).toBeVisible()
|
||||
await clearHistoryAction.click()
|
||||
}
|
||||
}
|
||||
77
browser_tests/fixtures/components/SignInDialog.ts
Normal file
77
browser_tests/fixtures/components/SignInDialog.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from './BaseDialog'
|
||||
|
||||
export class SignInDialog extends BaseDialog {
|
||||
readonly emailInput: Locator
|
||||
readonly passwordInput: Locator
|
||||
readonly signInButton: Locator
|
||||
readonly forgotPasswordLink: Locator
|
||||
readonly apiKeyButton: Locator
|
||||
readonly termsLink: Locator
|
||||
readonly privacyLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.emailInput = this.root.locator('#comfy-org-sign-in-email')
|
||||
this.passwordInput = this.root.locator('#comfy-org-sign-in-password')
|
||||
this.signInButton = this.root.getByRole('button', { name: 'Sign in' })
|
||||
this.forgotPasswordLink = this.root.getByText('Forgot password?')
|
||||
this.apiKeyButton = this.root.getByRole('button', {
|
||||
name: 'Comfy API Key'
|
||||
})
|
||||
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
||||
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.page.evaluate(() => {
|
||||
void window.app!.extensionManager.dialog.showSignInDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
get heading() {
|
||||
return this.root.getByRole('heading').first()
|
||||
}
|
||||
|
||||
get signUpLink() {
|
||||
return this.root.getByText('Sign up', { exact: true })
|
||||
}
|
||||
|
||||
get signInLink() {
|
||||
return this.root.getByText('Sign in', { exact: true })
|
||||
}
|
||||
|
||||
get signUpEmailInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-email')
|
||||
}
|
||||
|
||||
get signUpPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-password')
|
||||
}
|
||||
|
||||
get signUpConfirmPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-confirm-password')
|
||||
}
|
||||
|
||||
get signUpButton() {
|
||||
return this.root.getByRole('button', { name: 'Sign up', exact: true })
|
||||
}
|
||||
|
||||
get apiKeyHeading() {
|
||||
return this.root.getByRole('heading', { name: 'API Key' })
|
||||
}
|
||||
|
||||
get apiKeyInput() {
|
||||
return this.root.locator('#comfy-org-api-key')
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return this.root.getByRole('button', { name: 'Back' })
|
||||
}
|
||||
|
||||
get dividerText() {
|
||||
return this.root.getByText('Or continue with')
|
||||
}
|
||||
}
|
||||
41
browser_tests/fixtures/data/README.md
Normal file
41
browser_tests/fixtures/data/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Mock Data Fixtures
|
||||
|
||||
Deterministic mock data for browser (Playwright) tests. Each fixture
|
||||
exports typed objects that conform to generated types from
|
||||
`packages/ingest-types` or Zod schemas in `src/schemas/`.
|
||||
|
||||
## Usage with `page.route()`
|
||||
|
||||
> **Note:** `comfyPageFixture` navigates to the app during `setup()`,
|
||||
> before the test body runs. Routes must be registered before navigation
|
||||
> to intercept initial page-load requests. Set up routes in a custom
|
||||
> fixture or `test.beforeEach` that runs before `comfyPage.setup()`.
|
||||
|
||||
```ts
|
||||
import { createMockNodeDefinitions } from '../fixtures/data/nodeDefinitions'
|
||||
import { mockSystemStats } from '../fixtures/data/systemStats'
|
||||
|
||||
// Extend the base set with test-specific nodes
|
||||
const nodeDefs = createMockNodeDefinitions({
|
||||
MyCustomNode: {
|
||||
/* ... */
|
||||
}
|
||||
})
|
||||
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill({ json: nodeDefs })
|
||||
)
|
||||
|
||||
await page.route('**/api/system_stats', (route) =>
|
||||
route.fulfill({ json: mockSystemStats })
|
||||
)
|
||||
```
|
||||
|
||||
## Adding new fixtures
|
||||
|
||||
1. Locate the generated type in `packages/ingest-types` or Zod schema
|
||||
in `src/schemas/` for the endpoint you need.
|
||||
2. Create a new `.ts` file here that imports and satisfies the
|
||||
corresponding TypeScript type.
|
||||
3. Keep values realistic but stable — avoid dates, random IDs, or
|
||||
values that would cause test flakiness.
|
||||
155
browser_tests/fixtures/data/nodeDefinitions.ts
Normal file
155
browser_tests/fixtures/data/nodeDefinitions.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Base node definitions covering the default workflow.
|
||||
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
|
||||
*/
|
||||
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
|
||||
KSampler: {
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
seed: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 0xfffffffffffff,
|
||||
control_after_generate: true
|
||||
}
|
||||
],
|
||||
steps: ['INT', { default: 20, min: 1, max: 10000 }],
|
||||
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
|
||||
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
|
||||
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}],
|
||||
latent_image: ['LATENT', {}]
|
||||
},
|
||||
optional: {
|
||||
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Samples latents using the provided model and conditioning.',
|
||||
category: 'sampling',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CheckpointLoaderSimple: {
|
||||
input: {
|
||||
required: {
|
||||
ckpt_name: [
|
||||
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MODEL', 'CLIP', 'VAE'],
|
||||
output_is_list: [false, false, false],
|
||||
output_name: ['MODEL', 'CLIP', 'VAE'],
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads a diffusion model checkpoint.',
|
||||
category: 'loaders',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CLIPTextEncode: {
|
||||
input: {
|
||||
required: {
|
||||
text: ['STRING', { multiline: true, dynamicPrompts: true }],
|
||||
clip: ['CLIP', {}]
|
||||
}
|
||||
},
|
||||
output: ['CONDITIONING'],
|
||||
output_is_list: [false],
|
||||
output_name: ['CONDITIONING'],
|
||||
name: 'CLIPTextEncode',
|
||||
display_name: 'CLIP Text Encode (Prompt)',
|
||||
description: 'Encodes a text prompt using a CLIP model.',
|
||||
category: 'conditioning',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
EmptyLatentImage: {
|
||||
input: {
|
||||
required: {
|
||||
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'EmptyLatentImage',
|
||||
display_name: 'Empty Latent Image',
|
||||
description: 'Creates an empty latent image of the specified dimensions.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
VAEDecode: {
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT', {}],
|
||||
vae: ['VAE', {}]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_is_list: [false],
|
||||
output_name: ['IMAGE'],
|
||||
name: 'VAEDecode',
|
||||
display_name: 'VAE Decode',
|
||||
description: 'Decodes latent images back into pixel space.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
SaveImage: {
|
||||
input: {
|
||||
required: {
|
||||
images: ['IMAGE', {}],
|
||||
filename_prefix: ['STRING', { default: 'ComfyUI' }]
|
||||
}
|
||||
},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
name: 'SaveImage',
|
||||
display_name: 'Save Image',
|
||||
description: 'Saves images to the output directory.',
|
||||
category: 'image',
|
||||
output_node: true,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockNodeDefinitions(
|
||||
overrides?: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const base = structuredClone(baseNodeDefinitions)
|
||||
return overrides ? { ...base, ...overrides } : base
|
||||
}
|
||||
22
browser_tests/fixtures/data/systemStats.ts
Normal file
22
browser_tests/fixtures/data/systemStats.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { SystemStatsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
export const mockSystemStats: SystemStatsResponse = {
|
||||
system: {
|
||||
os: 'posix',
|
||||
python_version: '3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0]',
|
||||
embedded_python: false,
|
||||
comfyui_version: '0.3.10',
|
||||
pytorch_version: '2.4.0+cu124',
|
||||
argv: ['main.py', '--listen', '0.0.0.0'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
name: 'NVIDIA GeForce RTX 4090',
|
||||
type: 'cuda',
|
||||
vram_total: 25769803776,
|
||||
vram_free: 23622320128
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -83,6 +83,14 @@ export class AppModeHelper {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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").
|
||||
|
||||
@@ -4,12 +4,10 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
import { assetPath } from '../utils/paths'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly assetPath: (fileName: string) => string
|
||||
) {}
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
@@ -49,7 +47,7 @@ export class DragDropHelper {
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface PerfMeasurement {
|
||||
eventListeners: number
|
||||
totalBlockingTimeMs: number
|
||||
frameDurationMs: number
|
||||
p95FrameDurationMs: number
|
||||
allFrameDurationsMs: number[]
|
||||
}
|
||||
|
||||
export class PerformanceHelper {
|
||||
@@ -101,13 +103,13 @@ export class PerformanceHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure average frame duration via rAF timing over a sample window.
|
||||
* Returns average ms per frame (lower = better, 16.67 = 60fps).
|
||||
* Measure individual frame durations via rAF timing over a sample window.
|
||||
* Returns all per-frame durations so callers can compute avg, p95, etc.
|
||||
*/
|
||||
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
|
||||
private async measureFrameDurations(sampleFrames = 30): Promise<number[]> {
|
||||
return this.page.evaluate((frames) => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(0), 5000)
|
||||
return new Promise<number[]>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 5000)
|
||||
const timestamps: number[] = []
|
||||
let count = 0
|
||||
function tick(ts: number) {
|
||||
@@ -118,11 +120,14 @@ export class PerformanceHelper {
|
||||
} else {
|
||||
clearTimeout(timeout)
|
||||
if (timestamps.length < 2) {
|
||||
resolve(0)
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
const total = timestamps[timestamps.length - 1] - timestamps[0]
|
||||
resolve(total / (timestamps.length - 1))
|
||||
const durations: number[] = []
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
durations.push(timestamps[i] - timestamps[i - 1])
|
||||
}
|
||||
resolve(durations)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
@@ -177,11 +182,21 @@ export class PerformanceHelper {
|
||||
return after[key] - before[key]
|
||||
}
|
||||
|
||||
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
|
||||
const [totalBlockingTimeMs, allFrameDurationsMs] = await Promise.all([
|
||||
this.collectTBT(),
|
||||
this.measureFrameDuration()
|
||||
this.measureFrameDurations()
|
||||
])
|
||||
|
||||
const frameDurationMs =
|
||||
allFrameDurationsMs.length > 0
|
||||
? allFrameDurationsMs.reduce((a, b) => a + b, 0) /
|
||||
allFrameDurationsMs.length
|
||||
: 0
|
||||
|
||||
const sorted = [...allFrameDurationsMs].sort((a, b) => a - b)
|
||||
const p95FrameDurationMs =
|
||||
sorted.length > 0 ? sorted[Math.ceil(sorted.length * 0.95) - 1] : 0
|
||||
|
||||
return {
|
||||
name,
|
||||
durationMs: delta('Timestamp') * 1000,
|
||||
@@ -197,7 +212,9 @@ export class PerformanceHelper {
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
eventListeners: delta('JSEventListeners'),
|
||||
totalBlockingTimeMs,
|
||||
frameDurationMs
|
||||
frameDurationMs,
|
||||
p95FrameDurationMs,
|
||||
allFrameDurationsMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { assetPath } from '../utils/paths'
|
||||
|
||||
type FolderStructure = {
|
||||
[key: string]: FolderStructure | string
|
||||
@@ -20,7 +21,7 @@ export class WorkflowHelper {
|
||||
|
||||
for (const [key, value] of Object.entries(structure)) {
|
||||
if (typeof value === 'string') {
|
||||
const filePath = this.comfyPage.assetPath(value)
|
||||
const filePath = assetPath(value)
|
||||
result[key] = readFileSync(filePath, 'utf-8')
|
||||
} else {
|
||||
result[key] = this.convertLeafToContent(value)
|
||||
@@ -59,7 +60,7 @@ export class WorkflowHelper {
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.comfyPage.workflowUploadInput.setInputFiles(
|
||||
this.comfyPage.assetPath(`${workflowName}.json`)
|
||||
assetPath(`${workflowName}.json`)
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
@@ -146,6 +147,16 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
|
||||
?.isBusy,
|
||||
undefined,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
|
||||
async getExportedWorkflow(options?: {
|
||||
api?: false
|
||||
|
||||
@@ -20,6 +20,7 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
closeMinimapButton: 'close-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
@@ -51,7 +52,8 @@ export const TestIds = {
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button'
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
@@ -96,6 +98,10 @@ export const TestIds = {
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
@@ -124,4 +130,5 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
49
browser_tests/fixtures/utils/customMatchers.ts
Normal file
49
browser_tests/fixtures/utils/customMatchers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ExpectMatcherState, Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeReference } from './litegraphUtils'
|
||||
|
||||
function makeMatcher<T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
await expect(async () => {
|
||||
const value = await getValue(node)
|
||||
const assertion = this.isNot
|
||||
? expect(value, 'Node is ' + type).not
|
||||
: expect(value, 'Node is not ' + type)
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
await expect
|
||||
.poll(
|
||||
() => locator.evaluate((el) => el === document.activeElement),
|
||||
options
|
||||
)
|
||||
.toBe(!this.isNot)
|
||||
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
3
browser_tests/fixtures/utils/paths.ts
Normal file
3
browser_tests/fixtures/utils/paths.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
3
browser_tests/fixtures/utils/timing.ts
Normal file
3
browser_tests/fixtures/utils/timing.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -47,7 +47,8 @@ export function logMeasurement(
|
||||
export function recordMeasurement(m: PerfMeasurement) {
|
||||
mkdirSync(TEMP_DIR, { recursive: true })
|
||||
const filename = `${m.name}-${Date.now()}.json`
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
|
||||
const { allFrameDurationsMs: _, ...serializable } = m
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(serializable))
|
||||
}
|
||||
|
||||
export function writePerfReport(
|
||||
|
||||
@@ -10,10 +10,12 @@ import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
export class ComfyTemplates {
|
||||
readonly content: Locator
|
||||
readonly dialog: Locator
|
||||
readonly allTemplateCards: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.content = page.getByTestId(TestIds.templates.content)
|
||||
this.dialog = page.getByRole('dialog')
|
||||
this.allTemplateCards = page.locator('[data-testid^="template-workflow-"]')
|
||||
}
|
||||
|
||||
|
||||
@@ -145,10 +145,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
|
||||
29
browser_tests/tests/cloud.spec.ts
Normal file
29
browser_tests/tests/cloud.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
* These tests run against the cloud build (DISTRIBUTION=cloud) and verify
|
||||
* that cloud-specific behavior is present. In CI, no Firebase auth is
|
||||
* configured, so the auth guard redirects to /cloud/login. The tests
|
||||
* verify the cloud build loaded correctly by checking for cloud-only
|
||||
* routes and elements.
|
||||
*/
|
||||
test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
|
||||
.catch(() => {})
|
||||
}, longFilename)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
|
||||
|
||||
126
browser_tests/tests/dialogs/queueClearHistory.spec.ts
Normal file
126
browser_tests/tests/dialogs/queueClearHistory.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.queuePanel.overlayToggle.click()
|
||||
})
|
||||
|
||||
test('Dialog opens from queue panel history actions menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog shows confirmation message with title, description, and assets note', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'All the finished or failed jobs below will be removed from this Job queue panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Close (X) button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByLabel('Close').click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Confirm clears queue history and closes dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearPromise = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().includes('/api/history') && req.method() === 'POST'
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Clear' }).click()
|
||||
|
||||
const request = await clearPromise
|
||||
expect(request.postDataJSON()).toEqual({ clear: true })
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await expect(clearButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
94
browser_tests/tests/dialogs/signInDialog.spec.ts
Normal file
94
browser_tests/tests/dialogs/signInDialog.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { SignInDialog } from '../../fixtures/components/SignInDialog'
|
||||
|
||||
test.describe('Sign In dialog', { tag: '@ui' }, () => {
|
||||
let dialog: SignInDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
dialog = new SignInDialog(comfyPage.page)
|
||||
await dialog.open()
|
||||
})
|
||||
|
||||
test('Should open and show the sign-in form by default', async () => {
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
await expect(dialog.signInButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle from sign-in to sign-up form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.signUpEmailInput).toBeVisible()
|
||||
await expect(dialog.signUpPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpConfirmPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle back from sign-up to sign-in form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.signInLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should navigate to the API Key form and back', async () => {
|
||||
await dialog.apiKeyButton.click()
|
||||
|
||||
await expect(dialog.apiKeyHeading).toBeVisible()
|
||||
await expect(dialog.apiKeyInput).toBeVisible()
|
||||
|
||||
await dialog.backButton.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display Terms of Service and Privacy Policy links', async () => {
|
||||
await expect(dialog.termsLink).toBeVisible()
|
||||
await expect(dialog.termsLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/terms-of-service'
|
||||
)
|
||||
|
||||
await expect(dialog.privacyLink).toBeVisible()
|
||||
await expect(dialog.privacyLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/privacy'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display the "Or continue with" divider and API key button', async () => {
|
||||
await expect(dialog.dividerText).toBeVisible()
|
||||
await expect(dialog.apiKeyButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show forgot password link on sign-in form', async () => {
|
||||
await expect(dialog.forgotPasswordLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should close dialog via close button', async () => {
|
||||
await dialog.close()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should close dialog via Escape key', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
@@ -78,4 +78,66 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
await expect(minimap).not.toBeVisible()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(toggleButton).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = 3
|
||||
canvas.ds.offset[0] = -800
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
expect(minimapBox).toBeTruthy()
|
||||
expect(viewportBox).toBeTruthy()
|
||||
expect(viewportBox!.width).toBeGreaterThan(0)
|
||||
expect(viewportBox!.height).toBeGreaterThan(0)
|
||||
|
||||
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
|
||||
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
|
||||
minimapBox!.y
|
||||
)
|
||||
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
|
||||
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
34
browser_tests/tests/propertiesPanel/AGENTS.md
Normal file
34
browser_tests/tests/propertiesPanel/AGENTS.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Properties Panel E2E Tests
|
||||
|
||||
Tests for the right-side properties panel (`RightSidePanel.vue`).
|
||||
|
||||
## Structure
|
||||
|
||||
| File | Coverage |
|
||||
| --------------------------------- | ----------------------------------------------------------- |
|
||||
| `openClose.spec.ts` | Panel open/close via actionbar and close button |
|
||||
| `workflowOverview.spec.ts` | No-selection state: tabs, nodes list, global settings |
|
||||
| `nodeSelection.spec.ts` | Single/multi-node selection, selection changes, tab labels |
|
||||
| `titleEditing.spec.ts` | Node title editing via pencil icon |
|
||||
| `searchFiltering.spec.ts` | Widget search and clear |
|
||||
| `nodeSettings.spec.ts` | Settings tab: node state, color, pinned (requires VueNodes) |
|
||||
| `infoTab.spec.ts` | Node help content |
|
||||
| `errorsTab.spec.ts` | Errors tab visibility |
|
||||
| `propertiesPanelPosition.spec.ts` | Panel position relative to sidebar |
|
||||
|
||||
## Shared Helper
|
||||
|
||||
`PropertiesPanelHelper.ts` — Encapsulates panel locators and actions. Instantiated in `beforeEach`:
|
||||
|
||||
```typescript
|
||||
let panel: PropertiesPanelHelper
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Tests requiring VueNodes rendering enable it in `beforeEach` via `comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)` and call `comfyPage.vueNodes.waitForNodes()`.
|
||||
- Verify node state changes via user-facing indicators (text labels like "Bypassed"/"Muted", pin indicator test IDs) rather than internal properties.
|
||||
- Color changes are verified via `page.evaluate` accessing node properties, per the guidance in `docs/guidance/playwright.md`.
|
||||
100
browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts
Normal file
100
browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
export class PropertiesPanelHelper {
|
||||
readonly root: Locator
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.closeButton = this.root.locator('button[aria-pressed]')
|
||||
}
|
||||
|
||||
get tabs(): Locator {
|
||||
return this.root.locator('nav button')
|
||||
}
|
||||
|
||||
getTab(label: string): Locator {
|
||||
return this.root.locator('nav button', { hasText: label })
|
||||
}
|
||||
|
||||
get titleEditIcon(): Locator {
|
||||
return this.panelTitle.locator('i[class*="lucide--pencil"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.root.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
|
||||
return this.root.locator('button', { hasText: state })
|
||||
}
|
||||
|
||||
getColorSwatch(colorName: string): Locator {
|
||||
return this.root.locator(`[data-testid="${colorName}"]`)
|
||||
}
|
||||
|
||||
get pinnedSwitch(): Locator {
|
||||
return this.root.locator('[data-p-checked]').first()
|
||||
}
|
||||
|
||||
get subgraphEditButton(): Locator {
|
||||
return this.root.locator('button:has(i[class*="lucide--settings-2"])')
|
||||
}
|
||||
|
||||
get contentArea(): Locator {
|
||||
return this.root.locator('.scrollbar-thin')
|
||||
}
|
||||
|
||||
get errorsTabIcon(): Locator {
|
||||
return this.root.locator('nav i[class*="lucide--octagon-alert"]')
|
||||
}
|
||||
|
||||
get viewAllSettingsButton(): Locator {
|
||||
return this.root.getByRole('button', { name: /view all settings/i })
|
||||
}
|
||||
|
||||
get collapseToggleButton(): Locator {
|
||||
return this.root.locator(
|
||||
'button:has(i[class*="lucide--chevrons-down-up"]), button:has(i[class*="lucide--chevrons-up-down"])'
|
||||
)
|
||||
}
|
||||
|
||||
async open(actionbar: Locator): Promise<void> {
|
||||
if (!(await this.root.isVisible())) {
|
||||
await actionbar.click()
|
||||
await expect(this.root).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (await this.root.isVisible()) {
|
||||
await this.closeButton.click()
|
||||
await expect(this.root).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async switchToTab(label: string): Promise<void> {
|
||||
await this.getTab(label).click()
|
||||
}
|
||||
|
||||
async editTitle(newTitle: string): Promise<void> {
|
||||
await this.titleEditIcon.click()
|
||||
await this.titleInput.fill(newTitle)
|
||||
await this.titleInput.press('Enter')
|
||||
}
|
||||
|
||||
async searchWidgets(query: string): Promise<void> {
|
||||
await this.searchBox.fill(query)
|
||||
}
|
||||
|
||||
async clearSearch(): Promise<void> {
|
||||
await this.searchBox.fill('')
|
||||
}
|
||||
}
|
||||
31
browser_tests/tests/propertiesPanel/errorsTab.spec.ts
Normal file
31
browser_tests/tests/propertiesPanel/errorsTab.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Errors tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(panel.errorsTabIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Errors tab when errors are disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.errorsTabIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
22
browser_tests/tests/propertiesPanel/infoTab.spec.ts
Normal file
22
browser_tests/tests/propertiesPanel/infoTab.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Info tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Info')
|
||||
})
|
||||
|
||||
test('should show node help content', async () => {
|
||||
await expect(panel.contentArea).toBeVisible()
|
||||
await expect(
|
||||
panel.contentArea.getByRole('heading', { name: 'Inputs' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
126
browser_tests/tests/propertiesPanel/nodeSelection.spec.ts
Normal file
126
browser_tests/tests/propertiesPanel/nodeSelection.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node selection', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
})
|
||||
|
||||
test.describe('Single node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show node title in panel header', async () => {
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should show Parameters, Info, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Info')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Nodes tab for single node', async () => {
|
||||
await expect(panel.getTab('Nodes')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display node widgets in Parameters tab', async () => {
|
||||
await expect(panel.contentArea.getByText('seed')).toBeVisible()
|
||||
await expect(panel.contentArea.getByText('steps')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-node', () => {
|
||||
test('should show item count in title', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.panelTitle).toContainText('3 items selected')
|
||||
})
|
||||
|
||||
test('should list all selected nodes in Parameters tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
panel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should not show Info tab for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection changes', () => {
|
||||
test('should update from no selection to node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should update from node selection back to no selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should update between different single node selections', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nodeOps.selectNodes(['Empty Latent Image'])
|
||||
await expect(panel.panelTitle).toContainText('Empty Latent Image')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tab labels', () => {
|
||||
test('should show "Parameters" tab for single node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Nodes" tab label for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
122
browser_tests/tests/propertiesPanel/nodeSettings.spec.ts
Normal file
122
browser_tests/tests/propertiesPanel/nodeSettings.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node settings', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Settings')
|
||||
})
|
||||
|
||||
test.describe('Node state', () => {
|
||||
test('should show Normal, Bypass, and Mute state buttons', async () => {
|
||||
await expect(panel.getNodeStateButton('Normal')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Bypass')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Mute')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Bypass mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Mute mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Mute').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Muted')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should restore node to Normal mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
|
||||
await panel.getNodeStateButton('Normal').click()
|
||||
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
|
||||
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node color', () => {
|
||||
test('should display color swatches', async () => {
|
||||
await expect(panel.getColorSwatch('noColor')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('red')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('blue')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should apply color to node', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('should remove color with noColor swatch', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await panel.getColorSwatch('noColor').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color
|
||||
})
|
||||
)
|
||||
.toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pinned state', () => {
|
||||
test('should display pinned toggle', async () => {
|
||||
await expect(panel.pinnedSwitch).toBeVisible()
|
||||
})
|
||||
|
||||
test('should toggle pinned state', async ({ comfyPage }) => {
|
||||
await panel.pinnedSwitch.click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should unpin previously pinned node', async ({ comfyPage }) => {
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(
|
||||
nodeLocator.getByTestId('node-pin-indicator')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
32
browser_tests/tests/propertiesPanel/openClose.spec.ts
Normal file
32
browser_tests/tests/propertiesPanel/openClose.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Open and close', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should open via actionbar toggle button', async ({ comfyPage }) => {
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via panel close button', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.closeButton.click()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via close button after opening', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.close()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Properties panel', () => {
|
||||
test('opens and updates title based on selection', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
|
||||
await propertiesPanel.searchBox.fill('seed')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(0)
|
||||
|
||||
await propertiesPanel.searchBox.fill('')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
41
browser_tests/tests/propertiesPanel/searchFiltering.spec.ts
Normal file
41
browser_tests/tests/propertiesPanel/searchFiltering.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Search filtering', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
})
|
||||
|
||||
test('should filter nodes by search query', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('should restore all nodes when search is cleared', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await panel.clearSearch()
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
test('should show empty state for no matches', async () => {
|
||||
await panel.searchWidgets('nonexistent_widget_xyz')
|
||||
await expect(
|
||||
panel.contentArea.getByText(/no .* match|no results|no items/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
50
browser_tests/tests/propertiesPanel/titleEditing.spec.ts
Normal file
50
browser_tests/tests/propertiesPanel/titleEditing.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Title editing', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show pencil icon for editable title', async () => {
|
||||
await expect(panel.titleEditIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should enter edit mode on pencil click', async () => {
|
||||
await panel.titleEditIcon.click()
|
||||
await expect(panel.titleInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('should update node title on edit', async () => {
|
||||
const newTitle = 'My Custom Sampler'
|
||||
await panel.editTitle(newTitle)
|
||||
await expect(panel.panelTitle).toContainText(newTitle)
|
||||
})
|
||||
|
||||
test('should not show pencil icon for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show pencil icon when nothing is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
70
browser_tests/tests/propertiesPanel/workflowOverview.spec.ts
Normal file
70
browser_tests/tests/propertiesPanel/workflowOverview.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Workflow Overview', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Workflow Overview" title when nothing is selected', async () => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should show Parameters, Nodes, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Info tab when nothing is selected', async () => {
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Nodes tab and list all workflow nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await panel.switchToTab('Nodes')
|
||||
const nodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCount).toBeGreaterThan(0)
|
||||
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should filter nodes by search in Nodes tab', async () => {
|
||||
await panel.switchToTab('Nodes')
|
||||
await panel.searchWidgets('KSampler')
|
||||
await expect(panel.contentArea.getByText('KSampler').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Settings tab and show global settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "View all settings" button', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Nodes section with toggles', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(
|
||||
panel.contentArea.getByRole('button', { name: 'NODES' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Canvas section with grid settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Canvas')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Connection Links section', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Connection Links')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,23 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { components } from '@comfyorg/registry-types'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
function createMockRelease(overrides?: Partial<ReleaseNote>): ReleaseNote {
|
||||
return {
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Release Notifications', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -22,15 +37,10 @@ test.describe('Release Notifications', () => {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
createMockRelease({
|
||||
content:
|
||||
'## New Features\n\n- Added awesome feature\n- Fixed important bug',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
'## New Features\n\n- Added awesome feature\n- Fixed important bug'
|
||||
})
|
||||
])
|
||||
})
|
||||
} else {
|
||||
@@ -157,16 +167,7 @@ test.describe('Release Notifications', () => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'high',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
body: JSON.stringify([createMockRelease({ attention: 'high' })])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
@@ -250,16 +251,7 @@ test.describe('Release Notifications', () => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
body: JSON.stringify([createMockRelease()])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
@@ -303,14 +295,10 @@ test.describe('Release Notifications', () => {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
createMockRelease({
|
||||
attention: 'low',
|
||||
content: '## Bug Fixes\n\n- Fixed minor issue',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
content: '## Bug Fixes\n\n- Fixed minor issue'
|
||||
})
|
||||
])
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
|
||||
await assetCard.hover()
|
||||
await assetCard.getByLabel('Zoom in').click()
|
||||
|
||||
const gallery = comfyPage.page.getByRole('dialog')
|
||||
const gallery = comfyPage.mediaLightbox.root
|
||||
await expect(gallery).toBeVisible()
|
||||
|
||||
return { gallery }
|
||||
@@ -58,13 +58,13 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
|
||||
await runAndOpenGallery(comfyPage)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
|
||||
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes gallery when clicking close button', async ({ comfyPage }) => {
|
||||
const { gallery } = await runAndOpenGallery(comfyPage)
|
||||
|
||||
await gallery.getByLabel('Close').click()
|
||||
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
|
||||
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
|
||||
// TODO: Extract allNodeIds accessor into LGraph
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const isNonNegative = (id: number | string) =>
|
||||
typeof id === 'number' && id >= 0
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
isNonNegative(link.origin_id) &&
|
||||
!g._nodes_by_id[link.origin_id] &&
|
||||
`${label}: origin_id ${link.origin_id} not found`,
|
||||
isNonNegative(link.target_id) &&
|
||||
!g._nodes_by_id[link.target_id] &&
|
||||
`${label}: target_id ${link.target_id} not found`
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget DOM position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,165 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const RENAMED_NAME = 'renamed_slot_name'
|
||||
const SECOND_RENAMED_NAME = 'second_renamed_name'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Shows current slot label (not stale) in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial slot label
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
if (initialInputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an input slot label for rightClickInputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the rename worked
|
||||
const afterFirstRename = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph))
|
||||
return { label: null, name: null, displayName: null }
|
||||
const slot = graph.inputs?.[0]
|
||||
return {
|
||||
label: slot?.label || null,
|
||||
name: slot?.name || null,
|
||||
displayName: slot?.displayName || slot?.label || slot?.name || null
|
||||
}
|
||||
})
|
||||
expect(afterFirstRename.label).toBe(RENAMED_NAME)
|
||||
|
||||
// Now rename again - this is where the bug would show
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
|
||||
|
||||
// Complete the second rename to ensure everything still works
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the second rename worked
|
||||
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
|
||||
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
|
||||
})
|
||||
|
||||
test('Shows current output slot label in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial output slot label
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
if (initialOutputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an output slot label for rightClickOutputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now rename again to check for stale content
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickOutputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
})
|
||||
})
|
||||
@@ -1,856 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
promptDialog: '.graphdialog input',
|
||||
nodeSearchContainer: '.node-search-container',
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('I/O Slot Management', () => {
|
||||
test('Can add input slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add output slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('output')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can rename I/O slots', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename input slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename output slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const renamedOutputName = 'renamed_output'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
expect(newOutputName).toBe(renamedOutputName)
|
||||
expect(newOutputName).not.toBe(initialOutputLabel)
|
||||
})
|
||||
|
||||
test('Right-click context menu still works alongside double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Test that right-click still works for renaming
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const rightClickRenamedName = 'right_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(rightClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can double-click on slot label text to rename', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Use direct pointer event approach to double-click on label
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
|
||||
const graph = app.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) {
|
||||
throw new Error('Expected to be in subgraph')
|
||||
}
|
||||
const input = graph.inputs?.[0]
|
||||
|
||||
if (!input?.labelPos) {
|
||||
throw new Error('Could not get label position for testing')
|
||||
}
|
||||
|
||||
// Use labelPos for more precise clicking on the text
|
||||
const testX = input.labelPos[0]
|
||||
const testY = input.labelPos[1]
|
||||
|
||||
// Create a minimal mock event with required properties
|
||||
// Full PointerEvent creation is unnecessary for this test
|
||||
const leftClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 0,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as Parameters<typeof graph.inputNode.onPointerDown>[0]
|
||||
|
||||
const inputNode = graph.inputNode
|
||||
if (inputNode?.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
leftClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
|
||||
// Trigger double-click if pointer has the handler
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(leftClickEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const labelClickRenamedName = 'label_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(labelClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
test('Can create widget from link with compressed target_slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
const step = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.nodes[0].widgets![0].options.step
|
||||
})
|
||||
expect(step).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-duplicate-links'
|
||||
)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||
return { error: 'No subgraph node found' }
|
||||
}
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const linkCount = graph.links.size
|
||||
const nodes = graph.nodes
|
||||
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await node.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Can delete subgraph node', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Subgraph copy and paste', () => {
|
||||
test('Can copy subgraph node by dragging + alt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect a second subgraph node to be created (2 total)
|
||||
expect(subgraphNodes.length).toBe(2)
|
||||
})
|
||||
|
||||
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes and expect all unique IDs
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect the second subgraph node to have a unique type
|
||||
const nodeType1 = await subgraphNodes[0].getType()
|
||||
const nodeType2 = await subgraphNodes[1].getType()
|
||||
expect(nodeType1).not.toBe(nodeType2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operations Inside Subgraphs', () => {
|
||||
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.canvas.graph!.nodes
|
||||
return nodes?.[0]?.id || null
|
||||
})
|
||||
|
||||
expect(nodesInSubgraph).not.toBeNull()
|
||||
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
|
||||
String(nodesInSubgraph)
|
||||
)
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Add a node
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
// Undo
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterUndoCount).toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.keyboard.redo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterRedoCount).toBe(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Navigation and UI', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Breadcrumb updates when subgraph node title is changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
|
||||
// Navigate into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back and edit title
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y - 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
|
||||
test('Switching workflows while inside subgraph returns to root graph context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget visibility persists through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promoted widget is visible in parent graph
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify widget is visible in subgraph
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
// Navigate back
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify widget is still visible
|
||||
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('DOM widget content is preserved through navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await textarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const initialCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(initialCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(0)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable new menu for breadcrumb navigation
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const textareaCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(textareaCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
// Navigate into subgraph (method now handles retries internally)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Wait for breadcrumb to be visible
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that the subgraph node has no widgets after removing the text slot
|
||||
const widgetCount = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
|
||||
})
|
||||
|
||||
expect(widgetCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const parentCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(parentCount).toBeGreaterThan(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(subgraphCount).toBe(parentCount)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(parentCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Navigation Hotkeys', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Change the Exit Subgraph keybinding from Escape to Alt+Q
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'q',
|
||||
ctrl: false,
|
||||
alt: true,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'Escape',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Reload the page
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Test that Escape no longer exits subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Test that Alt+Q now exits subgraph
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Press Escape - should close dialog, not exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Press Escape again - now should exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
154
browser_tests/tests/subgraph/subgraphCrud.spec.ts
Normal file
154
browser_tests/tests/subgraph/subgraphCrud.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
|
||||
|
||||
test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-duplicate-links'
|
||||
)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||
return { error: 'No subgraph node found' }
|
||||
}
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const linkCount = graph.links.size
|
||||
const nodes = graph.nodes
|
||||
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await node.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Can delete subgraph node', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Subgraph copy and paste', () => {
|
||||
test('Can copy subgraph node by dragging + alt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect a second subgraph node to be created (2 total)
|
||||
expect(subgraphNodes.length).toBe(2)
|
||||
})
|
||||
|
||||
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes and expect all unique IDs
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect the second subgraph node to have a unique type
|
||||
const nodeType1 = await subgraphNodes[0].getType()
|
||||
const nodeType2 = await subgraphNodes[1].getType()
|
||||
expect(nodeType1).not.toBe(nodeType2)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,132 +1,19 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPseudoPreviewWidgets,
|
||||
getNonPreviewPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
} from '../../helpers/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Lifecycle Edge Behaviors',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
438
browser_tests/tests/subgraph/subgraphNavigation.spec.ts
Normal file
438
browser_tests/tests/subgraph/subgraphNavigation.spec.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
nodeSearchContainer: '.node-search-container'
|
||||
} as const
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Breadcrumb and Workflow Context', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Breadcrumb updates when subgraph node title is changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
|
||||
// Navigate into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back and edit title
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y - 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
|
||||
test('Switching workflows while inside subgraph returns to root graph context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Navigation Hotkeys', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Change the Exit Subgraph keybinding from Escape to Alt+Q
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'q',
|
||||
ctrl: false,
|
||||
alt: true,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'Escape',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Reload the page
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Test that Escape no longer exits subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Test that Alt+Q now exits subgraph
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Press Escape - should close dialog, not exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Press Escape again - now should exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Viewport', () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (
|
||||
n as unknown as { isSubgraphNode: () => boolean }
|
||||
).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Progress State', () => {
|
||||
test('Stale progress is cleared on subgraph node after navigating back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
// 1. User views root graph during execution
|
||||
// 2. Progress watcher sets node.progress = 0.5
|
||||
// 3. User enters subgraph
|
||||
// 4. Execution completes (nodeProgressStates becomes {})
|
||||
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
|
||||
// SubgraphNode isn't visible so it keeps stale progress
|
||||
// 6. User navigates back — watcher should fire and clear it
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
|
||||
// Verify progress is set
|
||||
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
expect(progressBefore).toBe(0.5)
|
||||
|
||||
// Navigate into the subgraph
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify we're inside the subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The progress watcher should fire when graph changes (because
|
||||
// nodeLocationProgressStates is empty {} and the watcher should
|
||||
// iterate canvas.graph.nodes to clear stale node.progress values).
|
||||
//
|
||||
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
|
||||
// fire on subgraph->root navigation when progress is already empty,
|
||||
// leaving stale node.progress = 0.5 on the SubgraphNode.
|
||||
await expect(async () => {
|
||||
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId)
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
461
browser_tests/tests/subgraph/subgraphNested.spec.ts
Normal file
461
browser_tests/tests/subgraph/subgraphNested.spec.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Nested subgraph configure order', () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page,
|
||||
['No link found', 'Failed to resolve legacy -1']
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('All three subgraph levels resolve promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const results = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
|
||||
return allGraphs.flatMap((g) =>
|
||||
g._nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((hostNode) => {
|
||||
const proxyWidgets = Array.isArray(
|
||||
hostNode.properties?.proxyWidgets
|
||||
)
|
||||
? hostNode.properties.proxyWidgets
|
||||
: []
|
||||
|
||||
const widgetEntries = proxyWidgets
|
||||
.filter(
|
||||
(e: unknown): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string'
|
||||
)
|
||||
.map(([interiorNodeId, widgetName]: [string, string]) => {
|
||||
const sg = hostNode.isSubgraphNode()
|
||||
? hostNode.subgraph
|
||||
: null
|
||||
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
|
||||
return {
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
resolved:
|
||||
interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(hostNode.id),
|
||||
widgetEntries
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
results.length,
|
||||
'Should have subgraph host nodes at multiple nesting levels'
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const { hostNodeId, widgetEntries } of results) {
|
||||
expect(
|
||||
widgetEntries.length,
|
||||
`Host node ${hostNodeId} should have promoted widgets`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(widgetName).toBeTruthy()
|
||||
expect(
|
||||
resolved,
|
||||
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Prompt execution succeeds without 400 error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status()).not.toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (
|
||||
(innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[]
|
||||
)
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
comfyExpect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
const textWidgets = widgetValues.filter((w) =>
|
||||
w.name.startsWith('text')
|
||||
)
|
||||
comfyExpect(textWidgets).toHaveLength(2)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await comfyExpect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for PR #10532:
|
||||
* Packing all nodes inside a subgraph into a nested subgraph was causing
|
||||
* the parent subgraph node's promoted widget values to go blank.
|
||||
*
|
||||
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
|
||||
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
|
||||
* at promotion time). After repointing, input._widget still pointed to
|
||||
* removed node IDs, causing missing-node failures and blank values on the
|
||||
* next checkState cycle.
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph pack preserves promoted widget values',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
|
||||
const HOST_NODE_ID = '57'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
|
||||
// 1. Verify initial promoted widget values via Vue node DOM
|
||||
const widthWidget = nodeLocator
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
const heightWidget = nodeLocator
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsWidget = nodeLocator
|
||||
.getByLabel('steps', { exact: true })
|
||||
.first()
|
||||
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthWidget)
|
||||
const heightControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightWidget)
|
||||
const stepsControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
||||
|
||||
await comfyExpect(async () => {
|
||||
await comfyExpect(widthControls.input).toHaveValue('1024')
|
||||
await comfyExpect(heightControls.input).toHaveValue('1024')
|
||||
await comfyExpect(stepsControls.input).toHaveValue('8')
|
||||
await comfyExpect(textWidget).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// 6. Re-enable Vue nodes and verify values are preserved
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeAfter).toBeVisible()
|
||||
|
||||
const widthAfter = nodeAfter
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
const heightAfter = nodeAfter
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsAfter = nodeAfter
|
||||
.getByLabel('steps', { exact: true })
|
||||
.first()
|
||||
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthAfter)
|
||||
const heightControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightAfter)
|
||||
const stepsControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
|
||||
|
||||
await comfyExpect(async () => {
|
||||
await comfyExpect(widthControlsAfter.input).toHaveValue('1024')
|
||||
await comfyExpect(heightControlsAfter.input).toHaveValue('1024')
|
||||
await comfyExpect(stepsControlsAfter.input).toHaveValue('8')
|
||||
await comfyExpect(textAfter).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Verify the host node is visible
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
|
||||
// Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// Verify all proxyWidgets entries resolve
|
||||
await comfyExpect(async () => {
|
||||
const result = await comfyPage.page.evaluate((hostId) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
) {
|
||||
return { error: 'Host node not found or not a subgraph node' }
|
||||
}
|
||||
|
||||
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
|
||||
const entries = (proxyWidgets as unknown[])
|
||||
.filter(
|
||||
(e): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string' &&
|
||||
!e[1].startsWith('$$')
|
||||
)
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(
|
||||
Number(nodeId)
|
||||
)
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return { entries, count: entries.length }
|
||||
}, HOST_NODE_ID)
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
const { entries, count } = result as {
|
||||
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
|
||||
count: number
|
||||
}
|
||||
expect(count).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
entry.resolved,
|
||||
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for nested subgraph packing leaving stale proxyWidgets
|
||||
* on the outer SubgraphNode.
|
||||
*
|
||||
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
|
||||
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
|
||||
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
|
||||
* Only ["3","seed"] (KSampler) should remain.
|
||||
*
|
||||
* Stale entries render as "Disconnected" placeholder widgets (type "button").
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph stale proxyWidgets',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
// Only the KSampler seed widget should be present — no stale
|
||||
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
|
||||
await comfyExpect(widgets).toHaveCount(1)
|
||||
await comfyExpect(widgets.first()).toBeVisible()
|
||||
|
||||
// Verify the seed widget is present via its label
|
||||
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidget).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
77
browser_tests/tests/subgraph/subgraphOperations.spec.ts
Normal file
77
browser_tests/tests/subgraph/subgraphOperations.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph Internal Operations',
|
||||
{ tag: ['@slow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.canvas.graph!.nodes
|
||||
return nodes?.[0]?.id || null
|
||||
})
|
||||
|
||||
expect(nodesInSubgraph).not.toBeNull()
|
||||
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
|
||||
String(nodesInSubgraph)
|
||||
)
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Add a node
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
// Undo
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterUndoCount).toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.keyboard.redo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterRedoCount).toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,13 +1,12 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../../helpers/fitToView'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
getPromotedWidgetCount
|
||||
} from '../../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
@@ -480,106 +479,6 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
|
||||
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vue Mode - Promoted Preview Content', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
216
browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts
Normal file
216
browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '../../helpers/promotedWidgets'
|
||||
|
||||
// Constants
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
|
||||
test.describe(
|
||||
'Subgraph Promoted Widget DOM',
|
||||
{ tag: ['@slow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Navigation and Persistence', () => {
|
||||
test('DOM widget visibility persists through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promoted widget is visible in parent graph
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify widget is visible in subgraph
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
// Navigate back
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify widget is still visible
|
||||
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('DOM widget content is preserved through navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await textarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const parentCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(parentCount).toBeGreaterThan(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(subgraphCount).toBe(parentCount)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(parentCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Cleanup', () => {
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const initialCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(initialCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(0)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable new menu for breadcrumb navigation
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const textareaCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(textareaCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
// Navigate into subgraph (method now handles retries internally)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Wait for breadcrumb to be visible
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that the subgraph node has no widgets after removing the text slot
|
||||
const widgetCount = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
|
||||
})
|
||||
|
||||
expect(widgetCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Positioning', () => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator
|
||||
.getByLabel('seed', { exact: true })
|
||||
.first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
async function createSubgraphAndNavigateInto(comfyPage: ComfyPage) {
|
||||
const subgraphNode =
|
||||
433
browser_tests/tests/subgraph/subgraphSerialization.spec.ts
Normal file
433
browser_tests/tests/subgraph/subgraphSerialization.spec.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '../../helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount
|
||||
} from '../../helpers/promotedWidgets'
|
||||
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
|
||||
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
|
||||
// TODO: Extract allNodeIds accessor into LGraph
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const isNonNegative = (id: number | string) =>
|
||||
typeof id === 'number' && id >= 0
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
isNonNegative(link.origin_id) &&
|
||||
!g._nodes_by_id[link.origin_id] &&
|
||||
`${label}: origin_id ${link.origin_id} not found`,
|
||||
isNonNegative(link.target_id) &&
|
||||
!g._nodes_by_id[link.target_id] &&
|
||||
`${label}: target_id ${link.target_id} not found`
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
comfyExpect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('Promoted widget renders with normalized name, not legacy prefix', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The promoted widget should render with the clean name "string_a",
|
||||
// not the legacy-prefixed "6: 3: string_a".
|
||||
const promotedWidget = outerNode
|
||||
.getByLabel('string_a', { exact: true })
|
||||
.first()
|
||||
await expect(promotedWidget).toBeVisible()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// Both widget rows should be valid "string_a" widgets — no stale
|
||||
// "Disconnected" placeholders from unresolved legacy entries.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widget value is editable as a text input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
762
browser_tests/tests/subgraph/subgraphSlots.spec.ts
Normal file
762
browser_tests/tests/subgraph/subgraphSlots.spec.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
const RENAMED_NAME = 'renamed_slot_name'
|
||||
const SECOND_RENAMED_NAME = 'second_renamed_name'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('I/O Slot CRUD', () => {
|
||||
test('Can add input slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add output slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('output')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename', () => {
|
||||
test('Can rename I/O slots via right-click context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename input slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename output slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const renamedOutputName = 'renamed_output'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
expect(newOutputName).toBe(renamedOutputName)
|
||||
expect(newOutputName).not.toBe(initialOutputLabel)
|
||||
})
|
||||
|
||||
test('Right-click context menu still works alongside double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Test that right-click still works for renaming
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const rightClickRenamedName = 'right_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(rightClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can double-click on slot label text to rename', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Use direct pointer event approach to double-click on label
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
|
||||
const graph = app.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) {
|
||||
throw new Error('Expected to be in subgraph')
|
||||
}
|
||||
const input = graph.inputs?.[0]
|
||||
|
||||
if (!input?.labelPos) {
|
||||
throw new Error('Could not get label position for testing')
|
||||
}
|
||||
|
||||
// Use labelPos for more precise clicking on the text
|
||||
const testX = input.labelPos[0]
|
||||
const testY = input.labelPos[1]
|
||||
|
||||
// Create a minimal mock event with required properties
|
||||
// Full PointerEvent creation is unnecessary for this test
|
||||
const leftClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 0,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as Parameters<typeof graph.inputNode.onPointerDown>[0]
|
||||
|
||||
const inputNode = graph.inputNode
|
||||
if (inputNode?.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
leftClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
|
||||
// Trigger double-click if pointer has the handler
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(leftClickEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const labelClickRenamedName = 'label_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(labelClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename Dialog', () => {
|
||||
test('Shows current slot label (not stale) in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial slot label
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
if (initialInputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an input slot label for rightClickInputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the rename worked
|
||||
const afterFirstRename = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph))
|
||||
return { label: null, name: null, displayName: null }
|
||||
const slot = graph.inputs?.[0]
|
||||
return {
|
||||
label: slot?.label || null,
|
||||
name: slot?.name || null,
|
||||
displayName: slot?.displayName || slot?.label || slot?.name || null
|
||||
}
|
||||
})
|
||||
expect(afterFirstRename.label).toBe(RENAMED_NAME)
|
||||
|
||||
// Now rename again - this is where the bug would show
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(
|
||||
SELECTORS.promptDialog
|
||||
)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
|
||||
|
||||
// Complete the second rename to ensure everything still works
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the second rename worked
|
||||
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
|
||||
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
|
||||
})
|
||||
|
||||
test('Shows current output slot label in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial output slot label
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
if (initialOutputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an output slot label for rightClickOutputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now rename again to check for stale content
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickOutputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(
|
||||
SELECTORS.promptDialog
|
||||
)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename Propagation', () => {
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await comfyExpect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = SELECTORS.promptDialog
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await comfyExpect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Compressed target_slot', () => {
|
||||
test('Can create widget from link with compressed target_slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
const step = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.nodes[0].widgets![0].options.step
|
||||
})
|
||||
expect(step).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Alignment', () => {
|
||||
/**
|
||||
* Regression test for link misalignment on SubgraphNodes when loading
|
||||
* workflows with workflowRendererVersion: "LG".
|
||||
*
|
||||
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
|
||||
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
|
||||
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
|
||||
* slot offsets. The fix uses DOM-relative measurement instead.
|
||||
*/
|
||||
test('slot positions stay within node bounds after loading LG workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const SLOT_BOUNDS_MARGIN = 20
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const workflowPath = resolve(
|
||||
import.meta.dirname,
|
||||
'../../assets/subgraphs/basic-subgraph.json'
|
||||
)
|
||||
const workflow = JSON.parse(
|
||||
readFileSync(workflowPath, 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
workflow.extra = {
|
||||
...workflow.extra,
|
||||
workflowRendererVersion: 'LG'
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
(wf) =>
|
||||
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
|
||||
openSource: 'template'
|
||||
}),
|
||||
workflow
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Slot Position', () => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = SELECTORS.promptDialog
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph input slot rename propagation',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy prefixed proxyWidget normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('Promoted widget renders with normalized name, not legacy prefix', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The promoted widget should render with the clean name "string_a",
|
||||
// not the legacy-prefixed "6: 3: string_a".
|
||||
const promotedWidget = outerNode
|
||||
.getByLabel('string_a', { exact: true })
|
||||
.first()
|
||||
await expect(promotedWidget).toBeVisible()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// Both widget rows should be valid "string_a" widgets — no stale
|
||||
// "Disconnected" placeholders from unresolved legacy entries.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(row.getByLabel('string_a', { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widget value is editable as a text input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page, [
|
||||
'No link found',
|
||||
'Failed to resolve legacy -1'
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('All three subgraph levels resolve promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const results = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
|
||||
return allGraphs.flatMap((g) =>
|
||||
g._nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((hostNode) => {
|
||||
const proxyWidgets = Array.isArray(
|
||||
hostNode.properties?.proxyWidgets
|
||||
)
|
||||
? hostNode.properties.proxyWidgets
|
||||
: []
|
||||
|
||||
const widgetEntries = proxyWidgets
|
||||
.filter(
|
||||
(e: unknown): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string'
|
||||
)
|
||||
.map(([interiorNodeId, widgetName]: [string, string]) => {
|
||||
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
|
||||
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
|
||||
return {
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(hostNode.id),
|
||||
widgetEntries
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
results.length,
|
||||
'Should have subgraph host nodes at multiple nesting levels'
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const { hostNodeId, widgetEntries } of results) {
|
||||
expect(
|
||||
widgetEntries.length,
|
||||
`Host node ${hostNodeId} should have promoted widgets`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(widgetName).toBeTruthy()
|
||||
expect(
|
||||
resolved,
|
||||
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status()).not.toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
expect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
|
||||
expect(textWidgets).toHaveLength(2)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
expect(values).toContain('11111111111')
|
||||
expect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await expect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Regression test for PR #10532:
|
||||
* Packing all nodes inside a subgraph into a nested subgraph was causing
|
||||
* the parent subgraph node's promoted widget values to go blank.
|
||||
*
|
||||
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
|
||||
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
|
||||
* at promotion time). After repointing, input._widget still pointed to
|
||||
* removed node IDs, causing missing-node failures and blank values on the
|
||||
* next checkState cycle.
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph pack preserves promoted widget values',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
|
||||
const HOST_NODE_ID = '57'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// 1. Verify initial promoted widget values via Vue node DOM
|
||||
const widthWidget = nodeLocator
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
const heightWidget = nodeLocator
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsWidget = nodeLocator
|
||||
.getByLabel('steps', { exact: true })
|
||||
.first()
|
||||
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthWidget)
|
||||
const heightControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightWidget)
|
||||
const stepsControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
||||
|
||||
await expect(async () => {
|
||||
await expect(widthControls.input).toHaveValue('1024')
|
||||
await expect(heightControls.input).toHaveValue('1024')
|
||||
await expect(stepsControls.input).toHaveValue('8')
|
||||
await expect(textWidget).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// 6. Re-enable Vue nodes and verify values are preserved
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeAfter).toBeVisible()
|
||||
|
||||
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
|
||||
const heightAfter = nodeAfter
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
|
||||
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthAfter)
|
||||
const heightControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightAfter)
|
||||
const stepsControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
|
||||
|
||||
await expect(async () => {
|
||||
await expect(widthControlsAfter.input).toHaveValue('1024')
|
||||
await expect(heightControlsAfter.input).toHaveValue('1024')
|
||||
await expect(stepsControlsAfter.input).toHaveValue('8')
|
||||
await expect(textAfter).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Verify the host node is visible
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// Verify all proxyWidgets entries resolve
|
||||
await expect(async () => {
|
||||
const result = await comfyPage.page.evaluate((hostId) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
) {
|
||||
return { error: 'Host node not found or not a subgraph node' }
|
||||
}
|
||||
|
||||
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
|
||||
const entries = (proxyWidgets as unknown[])
|
||||
.filter(
|
||||
(e): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string' &&
|
||||
!e[1].startsWith('$$')
|
||||
)
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId))
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return { entries, count: entries.length }
|
||||
}, HOST_NODE_ID)
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
const { entries, count } = result as {
|
||||
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
|
||||
count: number
|
||||
}
|
||||
expect(count).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
entry.resolved,
|
||||
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
|
||||
|
||||
/**
|
||||
* Regression test for nested subgraph packing leaving stale proxyWidgets
|
||||
* on the outer SubgraphNode.
|
||||
*
|
||||
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
|
||||
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
|
||||
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
|
||||
* Only ["3","seed"] (KSampler) should remain.
|
||||
*
|
||||
* Stale entries render as "Disconnected" placeholder widgets (type "button").
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph stale proxyWidgets',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
// Only the KSampler seed widget should be present — no stale
|
||||
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
|
||||
await expect(widgets).toHaveCount(1)
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
|
||||
// Verify the seed widget is present via its label
|
||||
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph progress clear on navigation',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('Stale progress is cleared on subgraph node after navigating back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
// 1. User views root graph during execution
|
||||
// 2. Progress watcher sets node.progress = 0.5
|
||||
// 3. User enters subgraph
|
||||
// 4. Execution completes (nodeProgressStates becomes {})
|
||||
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
|
||||
// SubgraphNode isn't visible so it keeps stale progress
|
||||
// 6. User navigates back — watcher should fire and clear it
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
|
||||
// Verify progress is set
|
||||
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
expect(progressBefore).toBe(0.5)
|
||||
|
||||
// Navigate into the subgraph
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify we're inside the subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The progress watcher should fire when graph changes (because
|
||||
// nodeLocationProgressStates is empty {} and the watcher should
|
||||
// iterate canvas.graph.nodes to clear stale node.progress values).
|
||||
//
|
||||
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
|
||||
// fire on subgraph->root navigation when progress is already empty,
|
||||
// leaving stale node.progress = 0.5 on the SubgraphNode.
|
||||
await expect(async () => {
|
||||
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId)
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,132 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for link misalignment on SubgraphNodes when loading
|
||||
* workflows with workflowRendererVersion: "LG".
|
||||
*
|
||||
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
|
||||
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
|
||||
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
|
||||
* slot offsets. The fix uses DOM-relative measurement instead.
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph slot alignment after LG layout scale',
|
||||
{ tag: ['@subgraph', '@canvas'] },
|
||||
() => {
|
||||
test('slot positions stay within node bounds after loading LG workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const SLOT_BOUNDS_MARGIN = 20
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const workflowPath = resolve(
|
||||
import.meta.dirname,
|
||||
'../assets/subgraphs/basic-subgraph.json'
|
||||
)
|
||||
const workflow = JSON.parse(
|
||||
readFileSync(workflowPath, 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
workflow.extra = {
|
||||
...workflow.extra,
|
||||
workflowRendererVersion: 'LG'
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
(wf) =>
|
||||
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
|
||||
openSource: 'template'
|
||||
}),
|
||||
workflow
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,114 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkflowTemplates } from '../../src/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function checkTemplateFileExists(
|
||||
page: Page,
|
||||
@@ -114,7 +116,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog').filter({
|
||||
const dialog = comfyPage.templates.dialog.filter({
|
||||
has: comfyPage.page.getByRole('heading', { name: 'Modèles', exact: true })
|
||||
})
|
||||
await expect(dialog).toBeVisible()
|
||||
@@ -218,8 +220,9 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Wait for filter bar select components to render
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
|
||||
const sortBySelect = comfyPage.templates.dialog.getByRole('combobox', {
|
||||
name: /Sort/
|
||||
})
|
||||
await expect(sortBySelect).toBeVisible()
|
||||
|
||||
// Screenshot the filter bar containing MultiSelect and SingleSelect
|
||||
@@ -243,7 +246,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await comfyPage.page.route(
|
||||
'**/templates/index.json',
|
||||
async (route, _) => {
|
||||
const response = [
|
||||
const response: WorkflowTemplates[] = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
@@ -345,4 +348,71 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'template cards display overlay tags correctly',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates: [
|
||||
{
|
||||
name: 'tagged-template',
|
||||
title: 'Tagged Template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'A template with tags.',
|
||||
tags: ['Relight', 'Image Edit']
|
||||
},
|
||||
{
|
||||
name: 'no-tags',
|
||||
title: 'No Tags',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'A template without tags.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
const taggedCard = comfyPage.page.getByTestId(
|
||||
TestIds.templates.workflowCard('tagged-template')
|
||||
)
|
||||
await expect(taggedCard).toBeVisible({ timeout: 5000 })
|
||||
await expect(taggedCard.getByText('Relight')).toBeVisible()
|
||||
await expect(taggedCard.getByText('Image Edit')).toBeVisible()
|
||||
|
||||
const templateGrid = comfyPage.page.getByTestId(TestIds.templates.content)
|
||||
await expect(templateGrid).toHaveScreenshot(
|
||||
'template-cards-with-overlay-tags.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
364
browser_tests/tests/workflowPersistence.spec.ts
Normal file
364
browser_tests/tests/workflowPersistence.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow Persistence', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Rapid tab switching does not desync workflow and graph state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — desynced workflow/graph state during loading'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-A')
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-B')
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await tab.switchToWorkflow('rapid-B')
|
||||
}
|
||||
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountA)
|
||||
})
|
||||
|
||||
test('Node outputs are preserved when switching workflow tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #9380 — ChangeTracker.store() did not save nodeOutputs, losing preview images on tab switch'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
|
||||
|
||||
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
expect(firstNode).toBeTruthy()
|
||||
const nodeId = firstNode!.id
|
||||
|
||||
// Simulate node outputs as if execution completed
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
const outputStore = window.app!.nodeOutputs
|
||||
if (outputStore) {
|
||||
outputStore[id] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}, String(nodeId))
|
||||
|
||||
// Trigger changeTracker to capture current state including outputs
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsBefore = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsBefore).toBeTruthy()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('outputs-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsAfter = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsAfter).toBeTruthy()
|
||||
expect(outputsAfter?.images).toBeDefined()
|
||||
})
|
||||
|
||||
test('Loading a new workflow cleanly replaces the previous graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commit 44bb6f13 — canvas graph not reset before workflow load'
|
||||
})
|
||||
|
||||
const defaultNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(defaultNodeCount).toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(1)
|
||||
|
||||
const nodes = await comfyPage.nodeOps.getNodes()
|
||||
expect(nodes[0].type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('Widget values on nodes are preserved across workflow tab switches', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #7648 — component widget state lost on graph change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('widget-state-test')
|
||||
|
||||
// Read widget values via page.evaluate — these are internal LiteGraph
|
||||
// state not exposed through DOM
|
||||
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('widget-state-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
|
||||
})
|
||||
|
||||
test('API format workflow with missing node types partially loads', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9694 — loadApiJson early-returned on missing node types'
|
||||
})
|
||||
|
||||
const fixturePath = comfyPage.assetPath(
|
||||
'nodes/api_workflow_with_missing_nodes.json'
|
||||
)
|
||||
const apiWorkflow = JSON.parse(readFileSync(fixturePath, 'utf-8'))
|
||||
|
||||
await comfyPage.page.evaluate(async (workflow) => {
|
||||
await window.app!.loadApiJson(workflow, 'test-api-workflow.json')
|
||||
}, apiWorkflow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Known nodes (KSampler, EmptyLatentImage) should load; unknown node skipped
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
|
||||
const nodeTypes = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.nodes.map((n: { type: string }) => n.type)
|
||||
})
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
expect(nodeTypes).toContain('EmptyLatentImage')
|
||||
expect(nodeTypes).not.toContain('NonExistentCustomNode_XYZ_12345')
|
||||
})
|
||||
|
||||
test('Canvas has auxclick handler to prevent middle-click paste', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8259 — middle-click paste duplicates entire workflow on Linux'
|
||||
})
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
button: 'middle',
|
||||
position: { x: 100, y: 100 }
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountAfter).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain transient blob: URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8715 — transient image URLs leaked into workflow serialization'
|
||||
})
|
||||
|
||||
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
for (const node of exportedWorkflow.nodes) {
|
||||
if (node.widgets_values && Array.isArray(node.widgets_values)) {
|
||||
for (const value of node.widgets_values) {
|
||||
if (typeof value === 'string') {
|
||||
expect(value).not.toMatch(/^blob:/)
|
||||
expect(value).not.toMatch(/^https?:\/\/.*\/api\/view/)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Changing locale does not break workflow operations', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #8963 — template workflows not reloaded on locale change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.menu.topbar.saveWorkflow('locale-test')
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
|
||||
await expect.poll(() => tab.getActiveWorkflowName()).toBe('locale-test')
|
||||
})
|
||||
|
||||
test('Node links survive save/load/switch cycles', async ({ comfyPage }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — node links must survive serialization roundtrips'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
// Link count requires internal graph state — not exposed via DOM
|
||||
const linkCountBefore = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountBefore).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('links-test')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('links-test')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const linkCountAfter = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commits 91f197d9d + a1b7e57bc — splitter panel size drift on reload'
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'Comfy.Splitter.MainSplitter',
|
||||
JSON.stringify([30, 70])
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const storedSizes = await comfyPage.page.evaluate(() => {
|
||||
const raw = localStorage.getItem('Comfy.Splitter.MainSplitter')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
|
||||
expect(storedSizes).toBeTruthy()
|
||||
expect(Array.isArray(storedSizes)).toBe(true)
|
||||
for (const size of storedSizes as number[]) {
|
||||
expect(typeof size).toBe('number')
|
||||
expect(size).toBeGreaterThanOrEqual(0)
|
||||
expect(size).not.toBeNaN()
|
||||
}
|
||||
const total = (storedSizes as number[]).reduce(
|
||||
(a: number, b: number) => a + b,
|
||||
0
|
||||
)
|
||||
expect(total).toBeGreaterThan(90)
|
||||
expect(total).toBeLessThanOrEqual(101)
|
||||
})
|
||||
})
|
||||
@@ -10,82 +10,191 @@ See `docs/testing/*.md` for detailed patterns.
|
||||
## Best Practices
|
||||
|
||||
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
|
||||
- Do NOT use `waitForTimeout` — use Locator actions and retrying assertions
|
||||
- Prefer specific selectors (role, label, test-id)
|
||||
- Test across viewports
|
||||
|
||||
## Window Globals
|
||||
|
||||
Browser tests access `window.app`, `window.graph`, and `window.LiteGraph` which are
|
||||
optional in the main app types. In E2E tests, use non-null assertions (`!`):
|
||||
optional in the main app types. Use non-null assertions (`!`) in E2E tests only:
|
||||
|
||||
```typescript
|
||||
window.app!.graph!.nodes
|
||||
window.LiteGraph!.registered_node_types
|
||||
```
|
||||
|
||||
This is the **only context** where non-null assertions are acceptable.
|
||||
TODO: Consolidate into a central utility (e.g., `getApp()`) with runtime type checking.
|
||||
|
||||
**TODO:** Consolidate these references into a central utility (e.g., `getApp()`) that
|
||||
performs proper runtime type checking, removing the need for scattered `!` assertions.
|
||||
## Type Assertions
|
||||
|
||||
## Type Assertions in E2E Tests
|
||||
Use specific type assertions when needed, never `as any`.
|
||||
|
||||
E2E tests may use **specific** type assertions when needed, but **never** `as any`.
|
||||
|
||||
### Acceptable Patterns
|
||||
Acceptable:
|
||||
|
||||
```typescript
|
||||
// ✅ Non-null assertions for window globals
|
||||
window.app!.extensionManager
|
||||
|
||||
// ✅ Specific type assertions with documentation
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestSetting' as TestSettingId
|
||||
|
||||
// ✅ Test-local type helpers
|
||||
type TestSettingId = keyof Settings
|
||||
```
|
||||
|
||||
### Forbidden Patterns
|
||||
Forbidden:
|
||||
|
||||
```typescript
|
||||
// ❌ Never use `as any`
|
||||
settings: testData as any
|
||||
|
||||
// ❌ Never modify production types to satisfy test errors
|
||||
// Don't add test settings to src/schemas/apiSchema.ts
|
||||
|
||||
// ❌ Don't chain through unknown to bypass types
|
||||
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
|
||||
data as unknown as SomeType
|
||||
```
|
||||
|
||||
### Accessing Internal State
|
||||
Access internal state via `page.evaluate` and stores directly — don't change public API types to expose internals.
|
||||
|
||||
When tests need internal store properties (e.g., `.workflow`, `.focusMode`):
|
||||
## Assertion Best Practices
|
||||
|
||||
Assert preconditions explicitly with a custom message so failures point to the broken assumption:
|
||||
|
||||
```typescript
|
||||
// ✅ Access stores directly in page.evaluate
|
||||
await page.evaluate(() => {
|
||||
const store = useWorkflowStore()
|
||||
return store.activeWorkflow
|
||||
})
|
||||
expect(node.widgets, 'Widget count changed — update test fixture').toHaveLength(
|
||||
4
|
||||
)
|
||||
await node.move(100, 200)
|
||||
|
||||
// ❌ Don't change public API types to expose internals
|
||||
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
|
||||
expect.soft(menuItem1).toBeVisible()
|
||||
expect.soft(menuItem2).toBeVisible()
|
||||
|
||||
// Bad — bare expect on a precondition gives no context when it fails
|
||||
expect(node.widgets).toHaveLength(4)
|
||||
```
|
||||
|
||||
- `expect(x, 'reason')` for precondition checks unrelated to the test's purpose
|
||||
- `expect.soft()` to verify multiple invariants without aborting on the first failure
|
||||
|
||||
## Test Structure: Arrange/Act/Assert
|
||||
|
||||
1. All mock setup, state resets, and fixture arrangement belongs in `test.beforeEach()` or Playwright fixtures
|
||||
2. Inside `test()`, only act (user actions) and assert
|
||||
3. Never call `clearAllMocks` or reset mock state mid-test
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('test.json')
|
||||
})
|
||||
test('should do something', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.click()
|
||||
await expect(comfyPage.menu.nodeLibraryTab.root).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
## Creating New Test Helpers
|
||||
|
||||
New domain-specific test helpers (e.g., `AssetHelper`, `JobHelper`) should be
|
||||
registered as Playwright fixtures via `base.extend()` rather than attached as
|
||||
properties on `ComfyPage`. This enables automatic setup/teardown.
|
||||
|
||||
### Extend `base` from Playwright
|
||||
|
||||
Keep each fixture self-contained by extending `@playwright/test` directly.
|
||||
Compose fixtures together with `mergeTests` when a test needs multiple helpers.
|
||||
|
||||
```typescript
|
||||
// browser_tests/fixtures/assetFixture.ts
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
export const test = base.extend<{
|
||||
assetHelper: AssetHelper
|
||||
}>({
|
||||
assetHelper: async ({ page }, use) => {
|
||||
const helper = new AssetHelper(page)
|
||||
await helper.setup()
|
||||
await use(helper)
|
||||
await helper.cleanup() // automatic teardown
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- **Do NOT** add new helpers as properties on `ComfyPage`
|
||||
- Each fixture gets automatic cleanup via the callback after `use()`
|
||||
- Keep fixtures modular — extend `@playwright/test` base, not
|
||||
`comfyPageFixture`, so they can be composed via `mergeTests`
|
||||
|
||||
## Custom Assertions
|
||||
|
||||
Add assertion methods directly on the page object or helper class instead of extending `comfyExpect`. Page object methods are discoverable via IntelliSense without special imports.
|
||||
|
||||
```typescript
|
||||
// ✅ Page object assertions
|
||||
await node.expectPinned()
|
||||
await node.expectBypassed()
|
||||
|
||||
// ❌ Do not add custom matchers to comfyExpect
|
||||
```
|
||||
|
||||
## Test Tags
|
||||
|
||||
Tags are respected by config:
|
||||
|
||||
- `@mobile` - Mobile viewport tests
|
||||
- `@2x` - High DPI tests
|
||||
- `@mobile` — Mobile viewport tests
|
||||
- `@2x` — High DPI tests
|
||||
|
||||
## Test Data
|
||||
|
||||
- Check `browser_tests/assets/` for test data and fixtures
|
||||
- Use realistic ComfyUI workflows for E2E tests
|
||||
- Check `browser_tests/assets/` for fixtures
|
||||
- Use realistic ComfyUI workflows
|
||||
- When multiple nodes share the same title, use `vueNodes.getNodeByTitle(name).nth(n)` — Playwright strict mode will fail on ambiguous locators
|
||||
|
||||
## Fixture Data & Schemas
|
||||
|
||||
When creating test fixture data, import or reference existing Zod schemas and TypeScript
|
||||
types from `src/` instead of inventing ad-hoc shapes. This keeps test data in sync with
|
||||
production types.
|
||||
|
||||
Key schema locations:
|
||||
|
||||
- `src/schemas/apiSchema.ts` — API response types (`PromptResponse`, `SystemStats`, `User`, `UserDataFullInfo`, WebSocket messages)
|
||||
- `src/schemas/nodeDefSchema.ts` — Node definition schema (`ComfyNodeDef`, `InputSpec`, `ComboInputSpec`)
|
||||
- `src/schemas/nodeDef/nodeDefSchemaV2.ts` — V2 node definition schema
|
||||
- `src/platform/remote/comfyui/jobs/jobTypes.ts` — Jobs API Zod schemas (`zJobDetail`, `zJobsListResponse`, `zRawJobListItem`)
|
||||
- `src/platform/workflow/validation/schemas/workflowSchema.ts` — Workflow validation (`ComfyWorkflowJSON`, `ComfyApiWorkflow`)
|
||||
- `src/types/metadataTypes.ts` — Asset metadata types
|
||||
|
||||
## Typed API Mocks
|
||||
|
||||
When mocking API responses with `route.fulfill()`, **always** type the response body
|
||||
using existing schemas or generated types — never use untyped inline JSON objects.
|
||||
This catches shape mismatches at compile time instead of through flaky runtime failures.
|
||||
|
||||
All three generated-type packages (`ingest-types`, `registry-types`, `generatedManagerTypes`)
|
||||
are auto-generated from their respective OpenAPI specs. Prefer these as the single
|
||||
source of truth for any mock that targets their endpoints.
|
||||
|
||||
### Sources of truth
|
||||
|
||||
| Endpoint category | Type source |
|
||||
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Cloud-only (hub, billing, workflows) | `@comfyorg/ingest-types` (`packages/ingest-types`, auto-generated from OpenAPI) |
|
||||
| Registry (releases, nodes, publishers) | `@comfyorg/registry-types` (`packages/registry-types`, auto-generated from OpenAPI) |
|
||||
| Manager (queue tasks, packages) | `generatedManagerTypes.ts` (`src/workbench/extensions/manager/types/`, auto-generated from OpenAPI) |
|
||||
| Python backend (queue, history, settings, features) | Manual Zod schemas in `src/schemas/apiSchema.ts` |
|
||||
| Node definitions | `src/schemas/nodeDefSchema.ts` |
|
||||
| Templates | `src/platform/workflow/templates/types/template.ts` |
|
||||
|
||||
### Patterns
|
||||
|
||||
```typescript
|
||||
// ✅ Import the type and annotate mock data
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
|
||||
const mockRelease: ReleaseNote = {
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
body: JSON.stringify([mockRelease])
|
||||
|
||||
// ❌ Untyped inline JSON — schema drift goes unnoticed
|
||||
body: JSON.stringify([{ id: 1, project: 'comfyui', version: 'v0.3.44', ... }])
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
|
||||
@@ -252,6 +252,22 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
// fixtures/data/ must contain only static data — no executable code or
|
||||
// Playwright imports. This enforces the architectural separation documented
|
||||
// in browser_tests/AGENTS.md.
|
||||
{
|
||||
files: ['browser_tests/fixtures/data/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'ImportDeclaration[source.value=/^@playwright/]',
|
||||
message:
|
||||
'fixtures/data/ must contain only static data. No Playwright imports allowed.'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['browser_tests/tests/**/*.test.ts'],
|
||||
rules: {
|
||||
@@ -392,6 +408,26 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
// Browser tests must use comfyPageFixture, not raw @playwright/test test
|
||||
{
|
||||
files: ['browser_tests/tests/**/*.spec.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@playwright/test',
|
||||
importNames: ['test'],
|
||||
message:
|
||||
"Use `comfyPageFixture as test` from the ComfyPage fixture module instead of raw `test` from '@playwright/test'."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Non-composable .ts files must use the global t/d/te, not useI18n()
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.8",
|
||||
"version": "1.43.9",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -8,6 +8,7 @@
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
@@ -36,7 +37,6 @@
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"storybook": "nx storybook",
|
||||
@@ -45,6 +45,7 @@
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/ingest-types": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
@@ -200,11 +202,21 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
"node": "24.x",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "catalog:"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@firebase/util",
|
||||
"core-js",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"unrs-resolver",
|
||||
"vue-demi"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,27 +9,7 @@ export default defineConfig({
|
||||
parser: {
|
||||
filters: {
|
||||
operations: {
|
||||
// Exclude endpoints that overlap with ComfyUI Python backend.
|
||||
// These are shared between local and cloud, with separate Zod
|
||||
// schemas already maintained in src/schemas/apiSchema.ts.
|
||||
exclude: [
|
||||
'/^GET \\/api\\/prompt$/',
|
||||
'/^POST \\/api\\/prompt$/',
|
||||
'/^GET \\/api\\/queue$/',
|
||||
'/^POST \\/api\\/queue$/',
|
||||
'/^GET \\/api\\/history$/',
|
||||
'/^POST \\/api\\/history$/',
|
||||
'/^GET \\/api\\/history_v2/',
|
||||
'/^GET \\/api\\/object_info$/',
|
||||
'/^GET \\/api\\/features$/',
|
||||
'/^GET \\/api\\/settings$/',
|
||||
'/^POST \\/api\\/settings$/',
|
||||
'/^GET \\/api\\/system_stats$/',
|
||||
'/^(GET|POST) \\/api\\/interrupt$/',
|
||||
'/^POST \\/api\\/upload\\//',
|
||||
'/^GET \\/api\\/view$/',
|
||||
'/^GET \\/api\\/jobs/',
|
||||
'/\\/api\\/userdata/',
|
||||
// Webhooks are server-to-server, not called by frontend
|
||||
'/\\/api\\/webhooks\\//',
|
||||
// Internal analytics endpoint
|
||||
|
||||
@@ -145,6 +145,11 @@ export type {
|
||||
DeleteSessionResponse,
|
||||
DeleteSessionResponse2,
|
||||
DeleteSessionResponses,
|
||||
DeleteUserdataFileData,
|
||||
DeleteUserdataFileError,
|
||||
DeleteUserdataFileErrors,
|
||||
DeleteUserdataFileResponse,
|
||||
DeleteUserdataFileResponses,
|
||||
DeleteWorkflowData,
|
||||
DeleteWorkflowError,
|
||||
DeleteWorkflowErrors,
|
||||
@@ -170,6 +175,12 @@ export type {
|
||||
ExchangeTokenResponse,
|
||||
ExchangeTokenResponse2,
|
||||
ExchangeTokenResponses,
|
||||
ExecutePromptData,
|
||||
ExecutePromptError,
|
||||
ExecutePromptErrors,
|
||||
ExecutePromptResponse,
|
||||
ExecutePromptResponses,
|
||||
ExecutionError,
|
||||
ExportDownloadUrlResponse,
|
||||
FeedbackRequest,
|
||||
FeedbackResponse,
|
||||
@@ -180,6 +191,11 @@ export type {
|
||||
ForkWorkflowRequest,
|
||||
ForkWorkflowResponse,
|
||||
ForkWorkflowResponses,
|
||||
GetAllSettingsData,
|
||||
GetAllSettingsError,
|
||||
GetAllSettingsErrors,
|
||||
GetAllSettingsResponse,
|
||||
GetAllSettingsResponses,
|
||||
GetAssetByIdData,
|
||||
GetAssetByIdError,
|
||||
GetAssetByIdErrors,
|
||||
@@ -220,6 +236,9 @@ export type {
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetFeaturesData,
|
||||
GetFeaturesResponse,
|
||||
GetFeaturesResponses,
|
||||
GetFilesData,
|
||||
GetFilesError,
|
||||
GetFilesErrors,
|
||||
@@ -235,6 +254,16 @@ export type {
|
||||
GetGlobalSubgraphsErrors,
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
GetHistoryErrors,
|
||||
GetHistoryForPromptData,
|
||||
GetHistoryForPromptError,
|
||||
GetHistoryForPromptErrors,
|
||||
GetHistoryForPromptResponse,
|
||||
GetHistoryForPromptResponses,
|
||||
GetHistoryResponse,
|
||||
GetHistoryResponses,
|
||||
GetHubProfileByUsernameData,
|
||||
GetHubProfileByUsernameError,
|
||||
GetHubProfileByUsernameErrors,
|
||||
@@ -250,6 +279,11 @@ export type {
|
||||
GetInviteCodeStatusErrors,
|
||||
GetInviteCodeStatusResponse,
|
||||
GetInviteCodeStatusResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
GetJobDetailResponse,
|
||||
GetJobDetailResponses,
|
||||
GetJobStatusData,
|
||||
GetJobStatusError,
|
||||
GetJobStatusErrors,
|
||||
@@ -288,16 +322,29 @@ export type {
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetNodeInfoData,
|
||||
GetNodeInfoResponse,
|
||||
GetNodeInfoResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
GetPaymentPortalResponse,
|
||||
GetPaymentPortalResponses,
|
||||
GetPromptInfoData,
|
||||
GetPromptInfoError,
|
||||
GetPromptInfoErrors,
|
||||
GetPromptInfoResponse,
|
||||
GetPromptInfoResponses,
|
||||
GetPublishedWorkflowData,
|
||||
GetPublishedWorkflowError,
|
||||
GetPublishedWorkflowErrors,
|
||||
GetPublishedWorkflowResponse,
|
||||
GetPublishedWorkflowResponses,
|
||||
GetQueueInfoData,
|
||||
GetQueueInfoError,
|
||||
GetQueueInfoErrors,
|
||||
GetQueueInfoResponse,
|
||||
GetQueueInfoResponses,
|
||||
GetRawLogsData,
|
||||
GetRawLogsError,
|
||||
GetRawLogsErrors,
|
||||
@@ -318,12 +365,34 @@ export type {
|
||||
GetSettingByKeyErrors,
|
||||
GetSettingByKeyResponse,
|
||||
GetSettingByKeyResponses,
|
||||
GetSystemStatsData,
|
||||
GetSystemStatsError,
|
||||
GetSystemStatsErrors,
|
||||
GetSystemStatsResponse,
|
||||
GetSystemStatsResponses,
|
||||
GetTaskData,
|
||||
GetTaskError,
|
||||
GetTaskErrors,
|
||||
GetTaskResponse,
|
||||
GetTaskResponses,
|
||||
GetUserData,
|
||||
GetUserdataData,
|
||||
GetUserdataError,
|
||||
GetUserdataErrors,
|
||||
GetUserdataFileData,
|
||||
GetUserdataFileError,
|
||||
GetUserdataFileErrors,
|
||||
GetUserdataFilePublishData,
|
||||
GetUserdataFilePublishError,
|
||||
GetUserdataFilePublishErrors,
|
||||
GetUserdataFilePublishResponse,
|
||||
GetUserdataFilePublishResponses,
|
||||
GetUserdataFileResponse,
|
||||
GetUserdataFileResponses,
|
||||
GetUserdataResponse,
|
||||
GetUserDataResponseFull,
|
||||
GetUserDataResponseFullFile,
|
||||
GetUserdataResponses,
|
||||
GetUserError,
|
||||
GetUserErrors,
|
||||
GetUserResponse,
|
||||
@@ -348,6 +417,11 @@ export type {
|
||||
GetWorkspaceResponses,
|
||||
GlobalSubgraphData,
|
||||
GlobalSubgraphInfo,
|
||||
HistoryDetailEntry,
|
||||
HistoryDetailResponse,
|
||||
HistoryEntry,
|
||||
HistoryManageRequest,
|
||||
HistoryResponse,
|
||||
HubAssetUploadUrlRequest,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
@@ -367,8 +441,15 @@ export type {
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InterruptJobData,
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
InterruptJobResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
JwksResponse,
|
||||
@@ -401,6 +482,11 @@ export type {
|
||||
ListHubWorkflowsResponse,
|
||||
ListHubWorkflowsResponses,
|
||||
ListInvitesResponse,
|
||||
ListJobsData,
|
||||
ListJobsError,
|
||||
ListJobsErrors,
|
||||
ListJobsResponse,
|
||||
ListJobsResponses,
|
||||
ListMembersResponse,
|
||||
ListSecretsData,
|
||||
ListSecretsError,
|
||||
@@ -441,9 +527,24 @@ export type {
|
||||
ListWorkspacesResponses,
|
||||
LogsResponse,
|
||||
LogsSubscribeRequest,
|
||||
ManageHistoryData,
|
||||
ManageHistoryError,
|
||||
ManageHistoryErrors,
|
||||
ManageHistoryResponses,
|
||||
ManageQueueData,
|
||||
ManageQueueError,
|
||||
ManageQueueErrors,
|
||||
ManageQueueResponse,
|
||||
ManageQueueResponses,
|
||||
Member,
|
||||
ModelFile,
|
||||
ModelFolder,
|
||||
MoveUserdataFileData,
|
||||
MoveUserdataFileError,
|
||||
MoveUserdataFileErrors,
|
||||
MoveUserdataFileResponse,
|
||||
MoveUserdataFileResponses,
|
||||
NodeInfo,
|
||||
PaginationInfo,
|
||||
PartnerUsageRequest,
|
||||
PartnerUsageResponse,
|
||||
@@ -459,6 +560,16 @@ export type {
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PostUserdataFileData,
|
||||
PostUserdataFileError,
|
||||
PostUserdataFileErrors,
|
||||
PostUserdataFilePublishData,
|
||||
PostUserdataFilePublishError,
|
||||
PostUserdataFilePublishErrors,
|
||||
PostUserdataFilePublishResponse,
|
||||
PostUserdataFilePublishResponses,
|
||||
PostUserdataFileResponse,
|
||||
PostUserdataFileResponses,
|
||||
PreviewPlanInfo,
|
||||
PreviewSubscribeData,
|
||||
PreviewSubscribeError,
|
||||
@@ -467,6 +578,10 @@ export type {
|
||||
PreviewSubscribeResponse,
|
||||
PreviewSubscribeResponse2,
|
||||
PreviewSubscribeResponses,
|
||||
PromptErrorResponse,
|
||||
PromptInfo,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
@@ -474,6 +589,10 @@ export type {
|
||||
PublishHubWorkflowRequest,
|
||||
PublishHubWorkflowResponse,
|
||||
PublishHubWorkflowResponses,
|
||||
PublishWorkflowAssetsRequest,
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RawLogsResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
@@ -537,6 +656,7 @@ export type {
|
||||
SubscribeToLogsResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SystemStatsResponse,
|
||||
TagInfo,
|
||||
TagsModificationResponse,
|
||||
TaskEntry,
|
||||
@@ -558,6 +678,11 @@ export type {
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
UpdateMultipleSettingsResponse,
|
||||
UpdateMultipleSettingsResponses,
|
||||
UpdateSecretData,
|
||||
UpdateSecretError,
|
||||
UpdateSecretErrors,
|
||||
@@ -586,13 +711,30 @@ export type {
|
||||
UploadAssetErrors,
|
||||
UploadAssetResponse,
|
||||
UploadAssetResponses,
|
||||
UploadImageData,
|
||||
UploadImageError,
|
||||
UploadImageErrors,
|
||||
UploadImageResponse,
|
||||
UploadImageResponses,
|
||||
UploadMaskData,
|
||||
UploadMaskError,
|
||||
UploadMaskErrors,
|
||||
UploadMaskResponse,
|
||||
UploadMaskResponses,
|
||||
UserDataResponseFull,
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
ViewFileData,
|
||||
ViewFileError,
|
||||
ViewFileErrors,
|
||||
ViewFileResponse,
|
||||
ViewFileResponses,
|
||||
WorkflowApiAssetsRequest,
|
||||
WorkflowApiAssetsResponse,
|
||||
WorkflowForkedFrom,
|
||||
WorkflowListResponse,
|
||||
WorkflowPublishInfo,
|
||||
WorkflowResponse,
|
||||
WorkflowVersionContentResponse,
|
||||
WorkflowVersionResponse,
|
||||
|
||||
1658
packages/ingest-types/src/types.gen.ts
generated
1658
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
664
packages/ingest-types/src/zod.gen.ts
generated
664
packages/ingest-types/src/zod.gen.ts
generated
@@ -216,6 +216,18 @@ export const zWorkflowApiAssetsRequest = z.object({
|
||||
workflow_api_json: z.record(z.unknown())
|
||||
})
|
||||
|
||||
export const zPublishWorkflowAssetsRequest = z.object({
|
||||
asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
export const zWorkflowPublishInfo = z.object({
|
||||
workflow_id: z.string(),
|
||||
share_id: z.string(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
listed: z.boolean(),
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zForkWorkflowRequest = z.object({
|
||||
source_version: z.number().int(),
|
||||
name: z.string().optional()
|
||||
@@ -756,6 +768,106 @@ export const zDeletionRequest = z.object({
|
||||
deletion_status: z.array(zDeletionStatus)
|
||||
})
|
||||
|
||||
/**
|
||||
* Detailed execution error information from ComfyUI
|
||||
*/
|
||||
export const zExecutionError = z.object({
|
||||
node_id: z.string(),
|
||||
node_type: z.string(),
|
||||
exception_message: z.string(),
|
||||
exception_type: z.string(),
|
||||
traceback: z.array(z.string()),
|
||||
current_inputs: z.record(z.unknown()),
|
||||
current_outputs: z.record(z.unknown())
|
||||
})
|
||||
|
||||
/**
|
||||
* Full job details including workflow and outputs
|
||||
*/
|
||||
export const zJobDetailResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
]),
|
||||
workflow: z.record(z.unknown()).optional(),
|
||||
execution_error: zExecutionError.optional(),
|
||||
create_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
update_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
outputs: z.record(z.unknown()).optional(),
|
||||
preview_output: z.record(z.unknown()).optional(),
|
||||
outputs_count: z.number().int().optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
execution_status: z.record(z.unknown()).optional(),
|
||||
execution_meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Lightweight job data for list views (workflow and full outputs excluded)
|
||||
*/
|
||||
export const zJobEntry = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
]),
|
||||
execution_error: zExecutionError.optional(),
|
||||
create_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
preview_output: z.record(z.unknown()).optional(),
|
||||
outputs_count: z.number().int().optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
execution_start_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
execution_end_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zJobsListResponse = z.object({
|
||||
jobs: z.array(zJobEntry),
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
export const zTagsModificationResponse = z.object({
|
||||
added: z.array(z.string()).optional(),
|
||||
removed: z.array(z.string()).optional(),
|
||||
@@ -931,6 +1043,33 @@ export const zUserResponse = z.object({
|
||||
status: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* System statistics response
|
||||
*/
|
||||
export const zSystemStatsResponse = z.object({
|
||||
system: z.object({
|
||||
os: z.string(),
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional(),
|
||||
cloud_version: z.string().optional(),
|
||||
pytorch_version: z.string(),
|
||||
argv: z.array(z.string()),
|
||||
ram_total: z.number(),
|
||||
ram_free: z.number()
|
||||
}),
|
||||
devices: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
vram_total: z.number().optional(),
|
||||
vram_free: z.number().optional()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
export const zLogsSubscribeRequest = z.object({
|
||||
enabled: z.boolean()
|
||||
})
|
||||
@@ -998,6 +1137,49 @@ export const zModelFolder = z.object({
|
||||
folders: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Error response for ComfyUI prompt execution.
|
||||
*/
|
||||
export const zPromptErrorResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetUserDataResponseFullFile = z.object({
|
||||
path: z.string().optional(),
|
||||
size: z.number().int().optional(),
|
||||
modified: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zGetUserDataResponseFull = z.array(zGetUserDataResponseFullFile)
|
||||
|
||||
export const zUserDataResponseFull = z.object({
|
||||
path: z.string().optional(),
|
||||
size: z.number().int().optional(),
|
||||
modified: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to manage history operations
|
||||
*/
|
||||
export const zHistoryManageRequest = z.object({
|
||||
delete: z.array(z.string()).optional(),
|
||||
clear: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Job status information
|
||||
*/
|
||||
@@ -1018,6 +1200,89 @@ export const zJobStatusResponse = z.object({
|
||||
error_message: z.string().nullish()
|
||||
})
|
||||
|
||||
export const zQueueManageResponse = z.object({
|
||||
deleted: z.array(z.string()).optional(),
|
||||
cleared: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to manage queue operations
|
||||
*/
|
||||
export const zQueueManageRequest = z.object({
|
||||
delete: z.array(z.string()).optional(),
|
||||
clear: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Queue information with pending and running jobs
|
||||
*/
|
||||
export const zQueueInfo = z.object({
|
||||
queue_running: z.array(z.array(z.unknown())).optional(),
|
||||
queue_pending: z.array(z.array(z.unknown())).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* History entry with full prompt data
|
||||
*/
|
||||
export const zHistoryDetailEntry = z.object({
|
||||
prompt: z
|
||||
.object({
|
||||
priority: z.number().optional(),
|
||||
prompt_id: z.string().optional(),
|
||||
prompt: z.record(z.unknown()).optional(),
|
||||
extra_data: z.record(z.unknown()).optional(),
|
||||
outputs_to_execute: z.array(z.string()).optional()
|
||||
})
|
||||
.optional(),
|
||||
outputs: z.record(z.unknown()).optional(),
|
||||
status: z.record(z.unknown()).optional(),
|
||||
meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Detailed execution history response for a specific prompt.
|
||||
* Returns a dictionary with prompt_id as key and full history data as value.
|
||||
*
|
||||
*/
|
||||
export const zHistoryDetailResponse = z.record(zHistoryDetailEntry)
|
||||
|
||||
/**
|
||||
* History entry with prompt_id and execution data
|
||||
*/
|
||||
export const zHistoryEntry = z.object({
|
||||
prompt_id: z.string(),
|
||||
create_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
prompt: z
|
||||
.object({
|
||||
priority: z.number().optional(),
|
||||
prompt_id: z.string().optional(),
|
||||
extra_data: z.record(z.unknown()).optional()
|
||||
})
|
||||
.optional(),
|
||||
outputs: z.record(z.unknown()).optional(),
|
||||
status: z.record(z.unknown()).optional(),
|
||||
meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Execution history response with history array.
|
||||
* Returns an object with a "history" key containing an array of history entries.
|
||||
* Each entry includes prompt_id as a property along with execution data.
|
||||
*
|
||||
*/
|
||||
export const zHistoryResponse = z.object({
|
||||
history: z.array(zHistoryEntry)
|
||||
})
|
||||
|
||||
/**
|
||||
* Full data for a global subgraph blueprint
|
||||
*/
|
||||
@@ -1042,6 +1307,32 @@ export const zGlobalSubgraphInfo = z.object({
|
||||
data: z.string().optional()
|
||||
})
|
||||
|
||||
export const zNodeInfo = z.object({
|
||||
input: z.record(z.unknown()).optional(),
|
||||
input_order: z.record(z.array(z.string())).optional(),
|
||||
output: z.array(z.string()).optional(),
|
||||
output_is_list: z.array(z.boolean()).optional(),
|
||||
output_name: z.array(z.string()).optional(),
|
||||
name: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
python_module: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
output_node: z.boolean().optional(),
|
||||
output_tooltips: z.array(z.string()).optional(),
|
||||
deprecated: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
api_node: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zPromptInfo = z.object({
|
||||
exec_info: z
|
||||
.object({
|
||||
queue_remaining: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zExportDownloadUrlResponse = z.object({
|
||||
url: z.string(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
@@ -1052,6 +1343,22 @@ export const zErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
export const zPromptResponse = z.object({
|
||||
prompt_id: z.string().uuid().optional(),
|
||||
number: z.number().optional(),
|
||||
node_errors: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
export const zPromptRequest = z.object({
|
||||
prompt: z.record(z.unknown()),
|
||||
number: z.number().optional(),
|
||||
front: z.boolean().optional(),
|
||||
extra_data: z.record(z.unknown()).optional(),
|
||||
partial_execution_targets: z.array(z.string()).optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
workflow_version_id: z.string().optional()
|
||||
})
|
||||
|
||||
export const zAssetWritable = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
@@ -1096,6 +1403,53 @@ export const zAssetCreatedWritable = zAssetWritable.and(
|
||||
*/
|
||||
export const zFeedbackResponseWritable = z.record(z.unknown())
|
||||
|
||||
export const zGetPromptInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetPromptInfoResponse = zPromptInfo
|
||||
|
||||
export const zExecutePromptData = z.object({
|
||||
body: zPromptRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Prompt accepted
|
||||
*/
|
||||
export const zExecutePromptResponse = zPromptResponse
|
||||
|
||||
export const zGetNodeInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetNodeInfoResponse = z.record(zNodeInfo)
|
||||
|
||||
export const zGetFeaturesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetFeaturesResponse = z.object({
|
||||
supports_preview_metadata: z.boolean().optional(),
|
||||
max_upload_size: z.number().int().optional()
|
||||
})
|
||||
|
||||
export const zGetWorkflowTemplatesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1170,6 +1524,97 @@ export const zGetModelPreviewData = z.object({
|
||||
*/
|
||||
export const zGetModelPreviewResponse = z.string()
|
||||
|
||||
export const zManageHistoryData = z.object({
|
||||
body: zHistoryManageRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetHistoryData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
max_items: z.number().int().optional(),
|
||||
offset: z.number().int().optional().default(0)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Execution history retrieved
|
||||
*/
|
||||
export const zGetHistoryResponse = zHistoryResponse
|
||||
|
||||
export const zGetHistoryForPromptData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
prompt_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - History for prompt retrieved
|
||||
*/
|
||||
export const zGetHistoryForPromptResponse = zHistoryDetailResponse
|
||||
|
||||
export const zListJobsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
status: z.string().optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
|
||||
sort_by: z.enum(['create_time', 'execution_time']).optional(),
|
||||
sort_order: z.enum(['asc', 'desc']).optional(),
|
||||
offset: z.number().int().gte(0).optional().default(0),
|
||||
limit: z.number().int().gte(1).lte(1000).optional().default(100)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Jobs retrieved
|
||||
*/
|
||||
export const zListJobsResponse = zJobsListResponse
|
||||
|
||||
export const zGetJobDetailData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
job_id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Job details retrieved
|
||||
*/
|
||||
export const zGetJobDetailResponse = zJobDetailResponse
|
||||
|
||||
export const zViewFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
filename: z.string(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
fullpath: z.string().optional(),
|
||||
format: z.string().optional(),
|
||||
frame_rate: z.number().int().optional(),
|
||||
workflow: z.string().optional(),
|
||||
timestamp: z.number().int().optional(),
|
||||
channel: z.string().optional(),
|
||||
res: z.number().int().gte(64).lte(1024).optional()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - File content returned (used when channel or res parameter is present)
|
||||
*/
|
||||
export const zViewFileResponse = z.string()
|
||||
|
||||
export const zGetMaskLayersData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1482,6 +1927,34 @@ export const zImportPublishedAssetsData = z.object({
|
||||
*/
|
||||
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
|
||||
|
||||
export const zGetQueueInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetQueueInfoResponse = zQueueInfo
|
||||
|
||||
export const zManageQueueData = z.object({
|
||||
body: zQueueManageRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zManageQueueResponse = zQueueManageResponse
|
||||
|
||||
export const zInterruptJobData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zListSecretsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1543,6 +2016,28 @@ export const zUpdateSecretData = z.object({
|
||||
*/
|
||||
export const zUpdateSecretResponse = zSecretResponse
|
||||
|
||||
export const zGetAllSettingsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* User settings as key-value pairs
|
||||
*/
|
||||
export const zGetAllSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zUpdateMultipleSettingsData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Updated user settings
|
||||
*/
|
||||
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetSettingByKeyData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -1584,6 +2079,164 @@ export const zSubmitFeedbackData = z.object({
|
||||
*/
|
||||
export const zSubmitFeedbackResponse = zFeedbackResponse
|
||||
|
||||
export const zGetUserdataData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
dir: z.string().optional(),
|
||||
recurse: z.boolean().optional().default(false),
|
||||
split: z.boolean().optional().default(false),
|
||||
full_info: z.boolean().optional().default(false)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* A list of user data files.
|
||||
*/
|
||||
export const zGetUserdataResponse = zGetUserDataResponseFull
|
||||
|
||||
export const zGetUserdataFilePublishData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Publish info (publish_time is null if never published)
|
||||
*/
|
||||
export const zGetUserdataFilePublishResponse = zWorkflowPublishInfo
|
||||
|
||||
export const zPostUserdataFilePublishData = z.object({
|
||||
body: zPublishWorkflowAssetsRequest,
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow published
|
||||
*/
|
||||
export const zPostUserdataFilePublishResponse = zWorkflowPublishInfo
|
||||
|
||||
export const zDeleteUserdataFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* File deleted successfully (No Content).
|
||||
*/
|
||||
export const zDeleteUserdataFileResponse = z.void()
|
||||
|
||||
export const zGetUserdataFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully retrieved the file.
|
||||
*/
|
||||
export const zGetUserdataFileResponse = z.string()
|
||||
|
||||
export const zPostUserdataFileData = z.object({
|
||||
body: z.string(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z
|
||||
.object({
|
||||
overwrite: z.enum(['true', 'false']).optional(),
|
||||
full_info: z.enum(['true', 'false']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* File uploaded successfully.
|
||||
*/
|
||||
export const zPostUserdataFileResponse = zUserDataResponseFull
|
||||
|
||||
export const zMoveUserdataFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string(),
|
||||
dest: z.string()
|
||||
}),
|
||||
query: z
|
||||
.object({
|
||||
overwrite: z.enum(['true', 'false']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* File moved successfully.
|
||||
*/
|
||||
export const zMoveUserdataFileResponse = zUserDataResponseFull
|
||||
|
||||
export const zUploadImageData = z.object({
|
||||
body: z.object({
|
||||
image: z.string(),
|
||||
overwrite: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Image uploaded successfully
|
||||
*/
|
||||
export const zUploadImageResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional()
|
||||
})
|
||||
|
||||
export const zUploadMaskData = z.object({
|
||||
body: z.object({
|
||||
image: z.string(),
|
||||
original_ref: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Mask uploaded successfully
|
||||
*/
|
||||
export const zUploadMaskResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
is_mask: z.boolean().optional(),
|
||||
original_hash: z.string().optional(),
|
||||
mask_type: z.string().optional(),
|
||||
related_files: z
|
||||
.object({
|
||||
mask: z.string().optional(),
|
||||
paint: z.string().optional(),
|
||||
painted: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zGetLogsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1619,6 +2272,17 @@ export const zSubscribeToLogsResponse = z.object({
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zGetSystemStatsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetSystemStatsResponse = zSystemStatsResponse
|
||||
|
||||
export const zDeleteSessionData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
|
||||
9265
packages/registry-types/src/comfyRegistryTypes.ts
generated
9265
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grepInvert: /@mobile|@perf|@audit/ // Run all tests except those tagged with @mobile, @perf, or @audit
|
||||
grepInvert: /@mobile|@perf|@audit|@cloud/ // Run all tests except those tagged with @mobile, @perf, @audit, or @cloud
|
||||
},
|
||||
|
||||
{
|
||||
@@ -85,6 +85,14 @@ export default defineConfig({
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
{
|
||||
name: 'cloud',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grep: /@cloud/, // Run only tests tagged with @cloud
|
||||
grepInvert: /@oss/ // Exclude tests tagged with @oss
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user