mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-30 17:25:45 +00:00
Compare commits
30 Commits
playwright
...
docs/weekl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd65ee0bfd | ||
|
|
f1d5337181 | ||
|
|
c289640e99 | ||
|
|
dc7c97c5ac | ||
|
|
8340d7655f | ||
|
|
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 |
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
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
|
||||
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
|
||||
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
|
||||
application behavior.
|
||||
|
||||
# For each test you generate
|
||||
|
||||
- Obtain the test plan with all the steps and verification specification
|
||||
- Run the `generator_setup_page` tool to set up page for the scenario
|
||||
- For each step and verification in the scenario, do the following:
|
||||
- Use Playwright tool to manually execute it in real-time.
|
||||
- Use the step description as the intent for each Playwright tool call.
|
||||
- Retrieve generator log via `generator_read_log`
|
||||
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
|
||||
- File should contain single test
|
||||
- File name must be fs-friendly scenario name
|
||||
- Test must be placed in a describe matching the top-level test plan item
|
||||
- Test title must match the scenario name
|
||||
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
|
||||
multiple actions.
|
||||
- Always use best practices from the log when generating tests.
|
||||
|
||||
<example-generation>
|
||||
For following plan:
|
||||
|
||||
```markdown file=specs/plan.md
|
||||
### 1. Adding New Todos
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1 Add Valid Todo
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Click in the "What needs to be done?" input field
|
||||
|
||||
#### 1.2 Add Multiple Todos
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
Following file is generated:
|
||||
|
||||
```ts file=add-valid-todo.spec.ts
|
||||
// spec: specs/plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
|
||||
test.describe('Adding New Todos', () => {
|
||||
test('Add Valid Todo', async { page } => {
|
||||
// 1. Click in the "What needs to be done?" input field
|
||||
await page.click(...);
|
||||
|
||||
...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
</example-generation>
|
||||
|
||||
## ComfyUI Project Context
|
||||
|
||||
### Required Import Pattern
|
||||
|
||||
Generated tests MUST use ComfyUI fixtures, not generic `@playwright/test`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
```
|
||||
|
||||
### Fixture Object
|
||||
|
||||
Tests receive `comfyPage` (not `page`) as their fixture:
|
||||
|
||||
```typescript
|
||||
test('my test', async ({ comfyPage }) => {
|
||||
// Access raw page via comfyPage.page if needed
|
||||
})
|
||||
```
|
||||
|
||||
### Key APIs
|
||||
|
||||
| Need | Use | Notes |
|
||||
| ---------------- | ---------------------------------------------------- | --------------------------------- |
|
||||
| Canvas element | `comfyPage.canvas` | Pre-configured Locator |
|
||||
| Wait for render | `comfyPage.nextFrame()` | After canvas mutations |
|
||||
| Load workflow | `comfyPage.workflow.loadWorkflow('name')` | Assets in `browser_tests/assets/` |
|
||||
| Get node by type | `comfyPage.nodeOps.getNodeRefsByType('KSampler')` | Returns NodeReference[] |
|
||||
| Search box | `comfyPage.searchBox.fillAndSelectFirstNode('name')` | Opens on canvas dblclick |
|
||||
| Settings | `comfyPage.settings.setSetting(key, value)` | Clean up in afterEach |
|
||||
| Keyboard | `comfyPage.keyboard.press('Delete')` | Focus canvas first |
|
||||
| Context menu | `comfyPage.contextMenu` | Right-click interactions |
|
||||
|
||||
### Mandatory Test Structure
|
||||
|
||||
Every generated test must:
|
||||
|
||||
1. Be wrapped in `test.describe('Name', { tag: ['@canvas'] }, () => { ... })`
|
||||
2. Include `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })`
|
||||
3. Use descriptive test names (not "test" or "test1")
|
||||
|
||||
### Anti-Patterns — NEVER Use
|
||||
|
||||
- ❌ `page.goto()` — fixture handles navigation
|
||||
- ❌ `page.waitForTimeout()` — use `comfyPage.nextFrame()` or retrying assertions
|
||||
- ❌ `import from '@playwright/test'` — use `from '../fixtures/ComfyPage'`
|
||||
- ❌ Bare `page.` references — use `comfyPage.page.` if you need raw page access
|
||||
|
||||
### Reference
|
||||
|
||||
Read the fixture code for full API surface:
|
||||
|
||||
- `browser_tests/fixtures/ComfyPage.ts` — main fixture
|
||||
- `browser_tests/fixtures/helpers/` — helper classes
|
||||
- `browser_tests/fixtures/components/` — page object components
|
||||
- See also: `.claude/skills/codegen-transform/SKILL.md` for transform rules
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
name: playwright-test-healer
|
||||
description: Use this agent when you need to debug and fix failing Playwright tests
|
||||
tools: Glob, Grep, Read, LS, Edit, MultiEdit, Write, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
|
||||
model: sonnet
|
||||
color: red
|
||||
---
|
||||
|
||||
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
|
||||
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
|
||||
broken Playwright tests using a methodical approach.
|
||||
|
||||
Your workflow:
|
||||
|
||||
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
|
||||
2. **Debug failed tests**: For each failing test run `test_debug`.
|
||||
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
|
||||
- Examine the error details
|
||||
- Capture page snapshot to understand the context
|
||||
- Analyze selectors, timing issues, or assertion failures
|
||||
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
|
||||
- Element selectors that may have changed
|
||||
- Timing and synchronization issues
|
||||
- Data dependencies or test environment problems
|
||||
- Application changes that broke test assumptions
|
||||
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
|
||||
- Updating selectors to match current application state
|
||||
- Fixing assertions and expected values
|
||||
- Improving test reliability and maintainability
|
||||
- For inherently dynamic data, utilize regular expressions to produce resilient locators
|
||||
6. **Verification**: Restart the test after each fix to validate the changes
|
||||
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
|
||||
|
||||
Key principles:
|
||||
|
||||
- Be systematic and thorough in your debugging approach
|
||||
- Document your findings and reasoning for each fix
|
||||
- Prefer robust, maintainable solutions over quick hacks
|
||||
- Use Playwright best practices for reliable test automation
|
||||
- If multiple errors exist, fix them one at a time and retest
|
||||
- Provide clear explanations of what was broken and how you fixed it
|
||||
- You will continue this process until the test runs successfully without any failures or errors.
|
||||
- If the error persists and you have high confidence the test is correct, do not auto-skip by default.
|
||||
- Summarize root-cause evidence and escalate as a likely app regression.
|
||||
- Use `test.fixme()` only when a known issue is documented and referenced, and include a short rationale comment.
|
||||
Auto-skipping can mask real regressions — require explicit justification.
|
||||
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
|
||||
- Never wait for networkidle or use other discouraged or deprecated apis
|
||||
|
||||
## ComfyUI Project Context
|
||||
|
||||
### Custom Fixtures
|
||||
|
||||
Tests in this project use `comfyPage` fixture, not bare `page`. When healing:
|
||||
|
||||
- Replace any `page.` references with `comfyPage.page.` if adding new code
|
||||
- Use `comfyPage.nextFrame()` instead of adding `waitForTimeout()`
|
||||
- Use fixture helpers (`comfyPage.nodeOps`, `comfyPage.canvas`, etc.) over raw locators
|
||||
|
||||
### Common Failure Causes in ComfyUI Tests
|
||||
|
||||
1. **Missing `nextFrame()`**: Canvas operations need `await comfyPage.nextFrame()` after mutations. This is the #1 cause of "works locally, fails in CI" issues.
|
||||
|
||||
2. **Canvas focus required**: Keyboard shortcuts won't work unless `await comfyPage.canvas.click()` is called first.
|
||||
|
||||
3. **Node position drift**: Pixel coordinates can shift between environments. When possible, replace with node references:
|
||||
|
||||
```typescript
|
||||
// Instead of: canvas.click({ position: { x: 423, y: 267 } })
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
await node.click('title')
|
||||
```
|
||||
|
||||
4. **Settings pollution**: Settings persist across tests on the backend. Always reset changed settings in `afterEach`.
|
||||
|
||||
5. **Drag animation timing**: Use `{ steps: 10 }` option for drag operations, not `{ steps: 1 }`.
|
||||
|
||||
### Healing Safety Rules
|
||||
|
||||
- ❌ NEVER add `waitForTimeout()` — always use retrying assertions or `nextFrame()`
|
||||
- ❌ NEVER "fix" a test by weakening assertions (e.g., removing an assertion that fails)
|
||||
- ❌ NEVER modify the application code — only modify test code
|
||||
- ⚠️ If a test fails because expected UI elements are missing, the app may have a regression — mark as `test.fixme()` with explanation, don't "heal" the assertion away
|
||||
- ⚠️ If a test fails only in CI but passes locally, likely missing `nextFrame()` — don't mask with timeouts
|
||||
|
||||
### Reference
|
||||
|
||||
- `browser_tests/fixtures/ComfyPage.ts` — full fixture API
|
||||
- `browser_tests/fixtures/helpers/` — available helper classes
|
||||
- `.claude/skills/writing-playwright-tests/SKILL.md` — testing conventions
|
||||
- `.claude/skills/codegen-transform/SKILL.md` — transform rules
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
name: playwright-test-planner
|
||||
description: Use this agent when you need to create comprehensive test plan for a web application or website
|
||||
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_run_code, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page, mcp__playwright-test__planner_save_plan
|
||||
model: sonnet
|
||||
color: green
|
||||
---
|
||||
|
||||
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
|
||||
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
|
||||
planning.
|
||||
|
||||
You will:
|
||||
|
||||
1. **Navigate and Explore**
|
||||
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
|
||||
- Explore the browser snapshot
|
||||
- Do not take screenshots unless absolutely necessary
|
||||
- Use `browser_*` tools to navigate and discover interface
|
||||
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
|
||||
|
||||
2. **Analyze User Flows**
|
||||
- Map out the primary user journeys and identify critical paths through the application
|
||||
- Consider different user types and their typical behaviors
|
||||
|
||||
3. **Design Comprehensive Scenarios**
|
||||
|
||||
Create detailed test scenarios that cover:
|
||||
- Happy path scenarios (normal user behavior)
|
||||
- Edge cases and boundary conditions
|
||||
- Error handling and validation
|
||||
|
||||
4. **Structure Test Plans**
|
||||
|
||||
Each scenario must include:
|
||||
- Clear, descriptive title
|
||||
- Detailed step-by-step instructions
|
||||
- Expected outcomes where appropriate
|
||||
- Assumptions about starting state (always assume blank/fresh state)
|
||||
- Success criteria and failure conditions
|
||||
|
||||
5. **Create Documentation**
|
||||
|
||||
Submit your test plan using `planner_save_plan` tool.
|
||||
|
||||
**Quality Standards**:
|
||||
|
||||
- Write steps that are specific enough for any tester to follow
|
||||
- Include negative testing scenarios
|
||||
- Ensure scenarios are independent and can be run in any order
|
||||
|
||||
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
|
||||
professional formatting suitable for sharing with development and QA teams.
|
||||
|
||||
## ComfyUI Project Context
|
||||
|
||||
### Application Overview
|
||||
|
||||
ComfyUI is a **canvas-based node graph editor** for AI image generation. It is a complex SPA with:
|
||||
|
||||
- A **LiteGraph canvas** where users create workflows by connecting nodes
|
||||
- A **Vue 3 sidebar** with node library, workflows panel, and settings
|
||||
- A **topbar** with queue/run buttons and workspace controls
|
||||
- A **search box** for finding and adding nodes (opens on double-click)
|
||||
- WebSocket-based real-time communication with a Python backend
|
||||
|
||||
### Exploration Tips
|
||||
|
||||
- Start by loading a workflow: the app is most useful with nodes on the canvas
|
||||
- Key UI areas to explore: canvas interactions, sidebar panels, topbar buttons, search box, context menus, settings dialog
|
||||
- Double-click the canvas to open the node search box
|
||||
- Right-click nodes/canvas for context menus
|
||||
- The bottom panel shows job queue and execution logs
|
||||
|
||||
### Test Environment
|
||||
|
||||
- The seed test uses `comfyPageFixture` which provides a `comfyPage` object with extensive helpers
|
||||
- Workflows (JSON files) are loaded via `comfyPage.workflow.loadWorkflow('name')`
|
||||
- Available workflow assets are in `browser_tests/assets/`
|
||||
- The backend MUST be running with `--multi-user` flag
|
||||
- A Vite dev server runs on `:5173`
|
||||
|
||||
### When Creating Test Plans
|
||||
|
||||
- Reference specific workflow assets when a scenario needs a starting state
|
||||
- Note that canvas interactions use pixel coordinates — these may vary across environments
|
||||
- Distinguish between "canvas tests" (LiteGraph) and "UI tests" (Vue components)
|
||||
- Include tags in your plans: `@canvas`, `@widget`, `@sidebar`, `@smoke`, `@screenshot`
|
||||
- Reference `browser_tests/fixtures/ComfyPage.ts` for available test helpers
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: codegen-transform
|
||||
description: 'Transforms raw Playwright codegen output into ComfyUI convention-compliant tests. Use when: user pastes raw codegen, asks to convert raw Playwright code, refactor recorded tests, or rewrite to project conventions. Triggers on: transform codegen, convert raw test, rewrite to conventions, codegen output, raw playwright.'
|
||||
---
|
||||
|
||||
# Codegen → Convention Transform
|
||||
|
||||
Transform raw Playwright codegen output into tests that follow ComfyUI conventions.
|
||||
|
||||
## When to Use
|
||||
|
||||
- QA tester recorded a test with `pnpm comfy-test record` and wants refinement
|
||||
- Developer pasted raw `npx playwright codegen` output
|
||||
- Agent needs to post-process Playwright test agent output
|
||||
- Reviewing a test that uses raw `page.*` calls instead of fixture helpers
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
Before transforming, read these existing docs for full context:
|
||||
|
||||
| Document | What it covers |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `docs/guidance/playwright.md` | Playwright conventions, type assertions, assertion best practices, tags |
|
||||
| `browser_tests/AGENTS.md` | Directory structure, polling assertions, gotchas, quality checks |
|
||||
| `browser_tests/fixtures/ComfyPage.ts` | Main fixture API (source of truth for all helpers) |
|
||||
| `browser_tests/fixtures/helpers/` | Focused helper classes (canvas, keyboard, workflow, etc.) |
|
||||
|
||||
## Transform Rules
|
||||
|
||||
The programmatic transform engine lives in `tools/test-recorder/src/transform/rules.ts`. Apply these replacements in order:
|
||||
|
||||
| Raw codegen | Convention replacement | Why |
|
||||
| ------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `import { test, expect } from '@playwright/test'` | `import { comfyPageFixture as test, comfyExpect as expect } from '../fixtures/ComfyPage'` | Use custom fixtures with ComfyUI helpers |
|
||||
| `test('test', async ({ page }) =>` | `test('descriptive-name', async ({ comfyPage }) =>` | Use comfyPage fixture, descriptive names |
|
||||
| `await page.goto('http://...')` | **Remove entirely** | Fixture handles navigation automatically |
|
||||
| `page.locator('canvas')` | `comfyPage.canvas` | Pre-configured canvas locator |
|
||||
| `page.waitForTimeout(N)` | `comfyPage.nextFrame()` | Never use arbitrary waits |
|
||||
| `page.getByPlaceholder('Search Nodes...')` | `comfyPage.searchBox.input` | Use search box page object |
|
||||
| `page` (bare reference) | `comfyPage.page` | Access raw page through fixture |
|
||||
| Bare `test(...)` | `test.describe('Feature', { tag: ['@canvas'] }, () => { test(...) })` | All tests need describe + tags |
|
||||
| No cleanup | Add `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })` | Canvas tests need cleanup |
|
||||
|
||||
## Canvas Coordinates → Node References
|
||||
|
||||
Raw codegen records fragile pixel coordinates. Replace with node references when possible:
|
||||
|
||||
```typescript
|
||||
// ❌ Raw codegen — fragile pixel coordinates
|
||||
await page.locator('canvas').click({ position: { x: 423, y: 267 } })
|
||||
|
||||
// ✅ If clicking a specific node
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
await node.click('title')
|
||||
|
||||
// ✅ If double-clicking canvas to open search
|
||||
await comfyPage.canvas.dblclick({ position: { x: 500, y: 400 } })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
```
|
||||
|
||||
**When to keep coordinates**: Canvas background clicks (pan, zoom), empty area clicks to deselect. These are inherently position-based.
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Question | Answer |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Canvas or DOM interaction? | Canvas: `comfyPage.nodeOps.*`. DOM: `comfyPage.vueNodes.*` (needs opt-in) |
|
||||
| Need `nextFrame()`? | Yes after canvas mutations. No after `loadWorkflow()`, no after DOM clicks |
|
||||
| Which tag? | `@canvas` for canvas tests, `@widget` for widget tests, `@screenshot` for visual regression |
|
||||
| Need cleanup? | Yes for canvas tests (`resetView`), yes if changing settings (`setSetting` back) |
|
||||
| Keep pixel coords? | Only for empty canvas clicks. Replace with node refs for node interactions |
|
||||
| Use `page` directly? | Only via `comfyPage.page` for Playwright APIs not wrapped by fixtures |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
1. **Never use `waitForTimeout`** → use `nextFrame()` or retrying assertions
|
||||
2. **Never use `page.goto`** → fixture handles navigation
|
||||
3. **Never import from `@playwright/test`** → use `../fixtures/ComfyPage`
|
||||
4. **Never use bare CSS selectors** → use test IDs or semantic locators
|
||||
5. **Never share state between tests** → each test is independent
|
||||
6. **Never commit local screenshots** → Linux CI generates baselines
|
||||
|
||||
## For Deeper Reference
|
||||
|
||||
Read fixture code directly — it's the source of truth:
|
||||
|
||||
| Purpose | Path |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
|
||||
| Helper classes | `browser_tests/fixtures/helpers/` |
|
||||
| Component objects | `browser_tests/fixtures/components/` |
|
||||
| Test selectors | `browser_tests/fixtures/selectors.ts` |
|
||||
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
|
||||
| Existing tests | `browser_tests/tests/` |
|
||||
| Test assets | `browser_tests/assets/` |
|
||||
@@ -95,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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -59,7 +59,6 @@ coverage/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser_tests/local/
|
||||
browser_tests/tests/_recording-session.spec.ts
|
||||
|
||||
.env
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-test": {
|
||||
"command": "pnpm",
|
||||
"args": ["dlx", "@playwright/mcp@0.0.68"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
"dist/*",
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"playwright-report/*",
|
||||
"scripts/patch-playwright-agents.js",
|
||||
"src/extensions/core/*",
|
||||
"src/scripts/*",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
@@ -115,12 +114,6 @@
|
||||
"no-console": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tools/test-recorder/**/*.ts"],
|
||||
"rules": {
|
||||
"no-console": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["browser_tests/**/*.ts"],
|
||||
"rules": {
|
||||
|
||||
16
AGENTS.md
16
AGENTS.md
@@ -49,21 +49,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
- `pnpm preview`: Preview the production build locally
|
||||
- `pnpm test:unit`: Run Vitest unit tests
|
||||
- `pnpm test:browser:local`: Run Playwright E2E tests (`browser_tests/`)
|
||||
- `pnpm comfy-test record`: Interactive test recorder (guided setup for non-devs)
|
||||
- `pnpm comfy-test transform <file>`: Transform raw codegen to conventions
|
||||
- `pnpm comfy-test check`: Check environment prerequisites
|
||||
- `pnpm comfy-test list`: List available test workflows
|
||||
|
||||
### Playwright Test Agents (`.claude/agents/`)
|
||||
|
||||
| Agent | Responsibility |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `playwright-test-planner.md` | Explores the app, identifies testable scenarios, creates structured test plans |
|
||||
| `playwright-test-generator.md` | Generates Playwright test code from plans using ComfyUI fixtures and conventions |
|
||||
| `playwright-test-healer.md` | Diagnoses and fixes failing tests; escalates regressions rather than auto-skipping |
|
||||
|
||||
Guardrails: agents must use `comfyPage` fixture (not bare `page`), never add `waitForTimeout()`, never weaken assertions, and reference `.claude/skills/codegen-transform/SKILL.md` for transform rules.
|
||||
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm format` / `pnpm format:check`: oxfmt
|
||||
- `pnpm typecheck`: Vue TSC type checking
|
||||
@@ -231,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
|
||||
|
||||
47
apps/website/src/components/AcademySection.vue
Normal file
47
apps/website/src/components/AcademySection.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.label"
|
||||
class="flex items-center gap-2 text-sm text-white"
|
||||
>
|
||||
<span aria-hidden="true">{{ feature.icon }}</span>
|
||||
<span>{{ feature.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
66
apps/website/src/components/CTASection.vue
Normal file
66
apps/website/src/components/CTASection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:href="card.href"
|
||||
class="flex flex-1 flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<span class="text-4xl" aria-hidden="true">{{ card.icon }}</span>
|
||||
<h3 class="mt-4 text-xl font-semibold text-white">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
<span
|
||||
class="mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
card.outlined
|
||||
? 'border border-brand-yellow text-brand-yellow'
|
||||
: 'bg-brand-yellow text-black'
|
||||
"
|
||||
>
|
||||
{{ card.cta }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<!-- TODO: Replace placeholder content with real quotes and case studies -->
|
||||
<script setup lang="ts">
|
||||
const studies = [
|
||||
{
|
||||
title: 'New Pipelines with Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'md:row-span-2'
|
||||
},
|
||||
{
|
||||
title: 'AI-Assisted Texture and Environment',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[300px] lg:col-span-2'
|
||||
},
|
||||
{
|
||||
title: 'Open-sourced the Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[200px]'
|
||||
},
|
||||
{
|
||||
title: 'Environment Generation',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: true,
|
||||
gridClass: 'min-h-[200px]'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-white">Customer Stories</h2>
|
||||
<p class="mt-2 text-smoke-700">
|
||||
See how leading studios use Comfy in production
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Bento grid -->
|
||||
<div
|
||||
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="study in studies"
|
||||
:key="study.title"
|
||||
class="flex flex-col justify-end rounded-2xl border border-brand-yellow/30 p-6"
|
||||
:class="[
|
||||
study.gridClass,
|
||||
study.highlight ? 'bg-brand-yellow' : 'bg-charcoal-600'
|
||||
]"
|
||||
>
|
||||
<h3
|
||||
class="font-semibold"
|
||||
:class="study.highlight ? 'text-black' : 'text-white'"
|
||||
>
|
||||
{{ study.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="mt-2 text-sm"
|
||||
:class="study.highlight ? 'text-black/70' : 'text-smoke-700'"
|
||||
>
|
||||
{{ study.body }}
|
||||
</p>
|
||||
<a
|
||||
href="/case-studies"
|
||||
class="mt-4 text-sm underline"
|
||||
:class="study.highlight ? 'text-black' : 'text-brand-yellow'"
|
||||
>
|
||||
READ CASE STUDY
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
62
apps/website/src/components/GetStartedSection.vue
Normal file
62
apps/website/src/components/GetStartedSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div v-for="(step, index) in steps" :key="step.number" class="relative">
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<span class="text-6xl font-bold text-brand-yellow/20">
|
||||
{{ step.number }}
|
||||
</span>
|
||||
<h3 class="mt-2 text-xl font-semibold text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
68
apps/website/src/components/HeroSection.vue
Normal file
68
apps/website/src/components/HeroSection.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-screen items-center overflow-hidden bg-black pt-16"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-12 px-6 md:flex-row md:gap-0"
|
||||
>
|
||||
<!-- Left: C Monogram -->
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
v-for="btn in ctaButtons"
|
||||
:key="btn.label"
|
||||
:href="btn.href"
|
||||
class="rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
btn.variant === 'solid'
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-brand-yellow text-brand-yellow'
|
||||
"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
26
apps/website/src/components/ManifestoSection.vue
Normal file
26
apps/website/src/components/ManifestoSection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<!-- Decorative quote mark -->
|
||||
<span class="text-6xl text-brand-yellow opacity-30" aria-hidden="true">
|
||||
«
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
<div
|
||||
class="mx-auto mt-8 h-0.5 w-24 bg-brand-yellow opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
51
apps/website/src/components/ProductShowcase.vue
Normal file
51
apps/website/src/components/ProductShowcase.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder video area -->
|
||||
<div
|
||||
class="mt-12 flex aspect-video items-center justify-center rounded-2xl border border-white/10 bg-charcoal-600"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature labels -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
58
apps/website/src/components/SocialProofBar.vue
Normal file
58
apps/website/src/components/SocialProofBar.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
'Nike',
|
||||
'HP',
|
||||
'Autodesk',
|
||||
'Apple',
|
||||
'Ubisoft',
|
||||
'Lucid',
|
||||
'Amazon',
|
||||
'Netflix',
|
||||
'Pixomondo',
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-y border-white/10 bg-black py-16">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
<div
|
||||
class="mt-10 flex flex-wrap items-center justify-center gap-4 md:gap-6"
|
||||
>
|
||||
<span
|
||||
v-for="company in logos"
|
||||
:key="company"
|
||||
class="rounded-full border border-white/10 px-6 py-2 text-sm text-smoke-700"
|
||||
>
|
||||
{{ company }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics row -->
|
||||
<div
|
||||
class="mt-14 flex flex-col items-center justify-center gap-10 sm:flex-row sm:gap-12"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="text-center">
|
||||
<p class="text-3xl font-bold text-white">{{ metric.value }}</p>
|
||||
<p class="mt-1 text-sm text-smoke-700">{{ metric.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
92
apps/website/src/components/TestimonialsSection.vue
Normal file
92
apps/website/src/components/TestimonialsSection.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
'Comfy has transformed our VFX pipeline. The node-based approach gives us unprecedented control over every step of the generation process.',
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'The level of control over AI generation is unmatched. We can iterate on game assets faster than ever before.',
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'We\u2019ve cut our iteration time by 70%. Comfy workflows let our team produce high-quality creative assets at scale.',
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
const filteredTestimonials = computed(() => {
|
||||
if (activeFilter.value === 'All') return testimonials
|
||||
return testimonials.filter((t) => t.industry === activeFilter.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
activeFilter === industry
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-white/10 text-smoke-700 hover:border-brand-yellow'
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Testimonial cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<article
|
||||
v-for="testimonial in filteredTestimonials"
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
<p class="mt-4 text-sm font-semibold text-white">
|
||||
{{ testimonial.name }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ testimonial.title }}, {{ testimonial.company }}
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
74
apps/website/src/components/UseCaseSection.vue
Normal file
74
apps/website/src/components/UseCaseSection.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="flex flex-col items-center gap-12 lg:flex-row lg:gap-8">
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
class="mt-10 flex flex-col items-center gap-4"
|
||||
aria-label="Industry categories"
|
||||
>
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
:key="category"
|
||||
class="transition-colors"
|
||||
:class="
|
||||
index === activeCategory
|
||||
? 'text-2xl text-white'
|
||||
: 'text-xl text-ash-500 hover:text-white/70'
|
||||
"
|
||||
@click="activeCategory = index"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
67
apps/website/src/components/ValuePillars.vue
Normal file
67
apps/website/src/components/ValuePillars.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-white">
|
||||
{{ pillar.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ pillar.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
34
apps/website/src/pages/index.astro
Normal file
34
apps/website/src/pages/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../components/SiteNav.vue'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import SocialProofBar from '../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../components/GetStartedSection.vue'
|
||||
import CTASection from '../components/CTASection.vue'
|
||||
import ManifestoSection from '../components/ManifestoSection.vue'
|
||||
import AcademySection from '../components/AcademySection.vue'
|
||||
import SiteFooter from '../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
34
apps/website/src/pages/zh-CN/index.astro
Normal file
34
apps/website/src/pages/zh-CN/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../../components/SiteNav.vue'
|
||||
import HeroSection from '../../components/HeroSection.vue'
|
||||
import SocialProofBar from '../../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../../components/GetStartedSection.vue'
|
||||
import CTASection from '../../components/CTASection.vue'
|
||||
import ManifestoSection from '../../components/ManifestoSection.vue'
|
||||
import AcademySection from '../../components/AcademySection.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -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).
|
||||
|
||||
@@ -70,32 +70,6 @@ await comfyPage.setup({ mockReleases: false })
|
||||
|
||||
For tests that specifically need to test release functionality, see the example in `tests/releaseNotifications.spec.ts`.
|
||||
|
||||
## Recording Tests (For Non-Developers)
|
||||
|
||||
If you're a QA tester or non-developer, use the interactive recorder:
|
||||
|
||||
```bash
|
||||
pnpm comfy-test record
|
||||
```
|
||||
|
||||
This guides you through a 7-step flow:
|
||||
|
||||
1. **Environment check** — verifies all tools are installed (with install instructions if not)
|
||||
2. **Project setup** — installs dependencies
|
||||
3. **Backend check** — ensures ComfyUI is running
|
||||
4. **Configure** — set test name, tags, and starting workflow
|
||||
5. **Record** — opens browser with Playwright Inspector for recording
|
||||
6. **Transform** — paste recorded code, auto-transforms to project conventions
|
||||
7. **PR creation** — creates a PR via `gh` CLI or gives manual instructions
|
||||
|
||||
Other commands:
|
||||
|
||||
```bash
|
||||
pnpm comfy-test check # Just run environment checks
|
||||
pnpm comfy-test transform <file> # Transform a raw codegen file
|
||||
pnpm comfy-test list # List available workflow assets
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
**Always use UI mode for development:**
|
||||
|
||||
@@ -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()
|
||||
@@ -124,7 +122,7 @@ type KeysOfType<T, Match> = {
|
||||
}[keyof T]
|
||||
|
||||
class ConfirmDialog {
|
||||
private readonly root: Locator
|
||||
public readonly root: Locator
|
||||
public readonly delete: Locator
|
||||
public readonly overwrite: Locator
|
||||
public readonly reject: Locator
|
||||
@@ -199,6 +197,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
|
||||
@@ -242,10 +241,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 +343,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 +359,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return sleep(ms)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,49 +477,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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Specs
|
||||
|
||||
This is a directory for test plans.
|
||||
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()
|
||||
})
|
||||
})
|
||||
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 {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Seed test for Playwright AI agents.
|
||||
*
|
||||
* This test bootstraps the ComfyUI environment for agent exploration.
|
||||
* When agents (Planner, Generator, Healer) run, they execute this test
|
||||
* first to set up the browser state, then use it as a template for
|
||||
* generated tests.
|
||||
*
|
||||
* Usage:
|
||||
* - Planner: Runs this to explore the app, then generates a test plan
|
||||
* - Generator: Uses this as an import/fixture template
|
||||
* - Healer: Runs this to establish baseline state
|
||||
*/
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test('seed', async ({ comfyPage }) => {
|
||||
// Load the default workflow — gives agents a realistic starting state
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the app is ready
|
||||
await expect(comfyPage.canvas).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,6 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -244,7 +245,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',
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,7 @@ ComfyUI's extension system follows these key principles:
|
||||
|
||||
## Core Extensions List
|
||||
|
||||
The following table lists ALL core extensions in the system as of 2025-01-30:
|
||||
The following table lists the main core extensions in the system. Additional extensions may exist for specialized features like cloud integration, badges, and advanced UI features. See `src/extensions/core/` for the complete list.
|
||||
|
||||
### Main Extensions
|
||||
|
||||
|
||||
@@ -10,107 +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
|
||||
|
||||
When tests need internal store properties (e.g., `.workflow`, `.focusMode`):
|
||||
|
||||
```typescript
|
||||
// ✅ Access stores directly in page.evaluate
|
||||
await page.evaluate(() => {
|
||||
const store = useWorkflowStore()
|
||||
return store.activeWorkflow
|
||||
})
|
||||
|
||||
// ❌ Don't change public API types to expose internals
|
||||
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
|
||||
```
|
||||
Access internal state via `page.evaluate` and stores directly — don't change public API types to expose internals.
|
||||
|
||||
## Assertion Best Practices
|
||||
|
||||
When a test depends on an invariant unrelated to what it's actually testing (e.g. asserting a node has 4 widgets before testing node movement), always assert that invariant explicitly — don't leave it unchecked. Use a custom message or `expect.soft()` rather than a bare `expect`, so failures point to the broken assumption instead of producing a confusing error downstream.
|
||||
Assert preconditions explicitly with a custom message so failures point to the broken assumption:
|
||||
|
||||
```typescript
|
||||
// ✅ Custom message on an unrelated precondition — clear signal when the invariant breaks
|
||||
expect(node.widgets, 'Widget count changed — update test fixture').toHaveLength(
|
||||
4
|
||||
)
|
||||
await node.move(100, 200)
|
||||
|
||||
// ✅ Soft assertion — verifies multiple invariants without stopping the test early
|
||||
expect.soft(menuItem1).toBeVisible()
|
||||
expect.soft(menuItem2).toBeVisible()
|
||||
expect.soft(menuItem3).toBeVisible()
|
||||
|
||||
// ❌ Bare expect on a precondition — no context when it fails
|
||||
// Bad — bare expect on a precondition gives no context when it fails
|
||||
expect(node.widgets).toHaveLength(4)
|
||||
```
|
||||
|
||||
- Use custom messages (`expect(x, 'reason')`) for precondition checks unrelated to the test's purpose
|
||||
- Use `expect.soft()` when you want to verify multiple invariants without aborting on the first failure
|
||||
- Prefer Playwright's built-in message parameter over custom error classes
|
||||
- `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
|
||||
- When multiple nodes share the same title (e.g. two "CLIP Text Encode" nodes), use `vueNodes.getNodeByTitle(name).nth(n)` to pick a specific one. Never interact with the bare locator when titles are non-unique — Playwright strict mode will fail.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -2,22 +2,70 @@
|
||||
|
||||
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
|
||||
|
||||
## Testing Libraries
|
||||
|
||||
This project supports two component testing approaches:
|
||||
|
||||
- **@testing-library/vue** (Recommended) - User-centric, behavior-focused testing with semantic queries
|
||||
- **@vue/test-utils** - Lower-level component API testing
|
||||
|
||||
Both are acceptable, but Testing Library is preferred for new tests as it encourages better testing practices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic Component Testing](#basic-component-testing)
|
||||
2. [PrimeVue Components Testing](#primevue-components-testing)
|
||||
3. [Tooltip Directives](#tooltip-directives)
|
||||
4. [Component Events Testing](#component-events-testing)
|
||||
5. [User Interaction Testing](#user-interaction-testing)
|
||||
6. [Asynchronous Component Testing](#asynchronous-component-testing)
|
||||
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
|
||||
1. [Basic Component Testing with Testing Library](#basic-component-testing-with-testing-library)
|
||||
2. [Basic Component Testing with Vue Test Utils](#basic-component-testing-with-vue-test-utils)
|
||||
3. [PrimeVue Components Testing](#primevue-components-testing)
|
||||
4. [Tooltip Directives](#tooltip-directives)
|
||||
5. [Component Events Testing](#component-events-testing)
|
||||
6. [User Interaction Testing](#user-interaction-testing)
|
||||
7. [Asynchronous Component Testing](#asynchronous-component-testing)
|
||||
8. [Working with Vue Reactivity](#working-with-vue-reactivity)
|
||||
|
||||
## Basic Component Testing
|
||||
## Basic Component Testing with Testing Library
|
||||
|
||||
Basic approach to testing a component's rendering and structure:
|
||||
User-centric approach using @testing-library/vue (recommended):
|
||||
|
||||
```typescript
|
||||
// Example from: src/components/sidebar/SidebarIcon.spec.ts
|
||||
// Example from: src/components/sidebar/SidebarIcon.test.ts
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
describe('SidebarIcon', () => {
|
||||
const exampleProps = {
|
||||
icon: 'pi pi-cog',
|
||||
selected: false,
|
||||
tooltip: 'Settings'
|
||||
}
|
||||
|
||||
it('renders as a button with accessible label', () => {
|
||||
render(SidebarIcon, { props: exampleProps })
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Settings' })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles click interactions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(SidebarIcon, {
|
||||
props: { ...exampleProps, onClick }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Basic Component Testing with Vue Test Utils
|
||||
|
||||
Lower-level API testing approach:
|
||||
|
||||
```typescript
|
||||
// Alternative approach using @vue/test-utils
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ it('should subscribe to logs API', () => {
|
||||
})
|
||||
```
|
||||
|
||||
## Mocking Lodash Functions
|
||||
## Mocking Utility Functions
|
||||
|
||||
Mocking utility functions like debounce:
|
||||
Mocking utility functions like debounce from es-toolkit:
|
||||
|
||||
```typescript
|
||||
// Mock debounce to execute immediately
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -38,10 +38,6 @@ const config: KnipConfig = {
|
||||
],
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
},
|
||||
'tools/test-recorder': {
|
||||
entry: ['src/index.ts'],
|
||||
project: ['src/**/*.ts']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.8",
|
||||
"version": "1.43.10",
|
||||
"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",
|
||||
@@ -42,9 +43,9 @@
|
||||
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"comfy-test": "tsx tools/test-recorder/src/index.ts",
|
||||
"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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user