mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
83 Commits
fix/dropdo
...
pysssss/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
702f47f3e1 | ||
|
|
0da4362205 | ||
|
|
9363d31ad4 | ||
|
|
955427b59f | ||
|
|
e181339159 | ||
|
|
e4059303d4 | ||
|
|
5796c6d464 | ||
|
|
dce8b87759 | ||
|
|
bd82c855e0 | ||
|
|
5b7ef3fe21 | ||
|
|
85de833776 | ||
|
|
cab46567c0 | ||
|
|
63435bdb34 | ||
|
|
20255da61f | ||
|
|
c2dba8f4ee | ||
|
|
6f579c5992 | ||
|
|
e729e5edb8 | ||
|
|
3043b181d7 | ||
|
|
8c9328c1b2 | ||
|
|
577f373cde | ||
|
|
44f88027b6 | ||
|
|
5d07de1913 | ||
|
|
f0ae91de43 | ||
|
|
fb8025c49f | ||
|
|
5c14badc42 | ||
|
|
82fb8ce658 | ||
|
|
c3e823e55b | ||
|
|
ebc9025de5 | ||
|
|
0353524e6f | ||
|
|
f1bb756929 | ||
|
|
d70c3cbfc2 | ||
|
|
e38dd1efae | ||
|
|
62779d3c51 | ||
|
|
4cf160d66e | ||
|
|
c0871ba219 | ||
|
|
6d4fc1bb10 | ||
|
|
0132c77c7d | ||
|
|
63eab15c4f | ||
|
|
277ee5c32e | ||
|
|
e8787dee9d | ||
|
|
ba0bab3e50 | ||
|
|
bbb07053c4 | ||
|
|
97fca566fb | ||
|
|
c6b8883e61 | ||
|
|
8487c13f14 | ||
|
|
833a2f5161 | ||
|
|
4f97a9028c | ||
|
|
3786a46ef7 | ||
|
|
5c3de0081b | ||
|
|
af77920f8f | ||
|
|
c00e285768 | ||
|
|
8f41bc7527 | ||
|
|
11b62c48e3 | ||
|
|
cc3d3f1d25 | ||
|
|
92e65aaaa7 | ||
|
|
f82f8624e1 | ||
|
|
c46316d248 | ||
|
|
8e5dc15e5d | ||
|
|
da2fedebcf | ||
|
|
2a531ff80b | ||
|
|
b6234b96af | ||
|
|
bd66617d3f | ||
|
|
98eac41f07 | ||
|
|
307a1c77c0 | ||
|
|
bbd1e60f7b | ||
|
|
9100058fc1 | ||
|
|
04c00aadd8 | ||
|
|
2f1615c505 | ||
|
|
cf4dfceaee | ||
|
|
dbb70323bf | ||
|
|
6689510591 | ||
|
|
82e62694a9 | ||
|
|
d49f263536 | ||
|
|
d30bb01b4b | ||
|
|
320cd82f0d | ||
|
|
8a30211bea | ||
|
|
12fd0981a8 | ||
|
|
0772f2a7fe | ||
|
|
08666d8e81 | ||
|
|
d18243e085 | ||
|
|
3cba424e52 | ||
|
|
0f3b2e0455 | ||
|
|
fd31f9d0ed |
246
.claude/skills/hardening-flaky-e2e-tests/SKILL.md
Normal file
246
.claude/skills/hardening-flaky-e2e-tests/SKILL.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
name: hardening-flaky-e2e-tests
|
||||||
|
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hardening Flaky E2E Tests
|
||||||
|
|
||||||
|
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Gather CI Evidence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh run list --workflow=ci-test.yaml --limit=5
|
||||||
|
gh run download <run-id> -n playwright-report
|
||||||
|
```
|
||||||
|
|
||||||
|
- Open `report.json` and search for `"status": "flaky"` entries.
|
||||||
|
- Collect file paths, test titles, and error messages.
|
||||||
|
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
|
||||||
|
- Use `error-context.md`, traces, and page snapshots before editing code.
|
||||||
|
- Pull the newest run after each push instead of assuming the flaky set is unchanged.
|
||||||
|
|
||||||
|
### 2. Classify the Flake
|
||||||
|
|
||||||
|
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
|
||||||
|
|
||||||
|
| # | Pattern | Signature in Code | Fix |
|
||||||
|
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||||
|
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||||
|
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||||
|
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
|
||||||
|
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||||
|
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||||
|
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||||
|
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
|
||||||
|
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||||
|
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
|
||||||
|
|
||||||
|
### 3. Apply the Transform
|
||||||
|
|
||||||
|
#### Rule: Choose the Smallest Correct Assertion
|
||||||
|
|
||||||
|
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
|
||||||
|
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
|
||||||
|
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
|
||||||
|
- **Never** use `waitForTimeout()` to hide a race.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Single value — use expect.poll
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
|
||||||
|
.toBe(3)
|
||||||
|
|
||||||
|
// ✅ Locator count — use toHaveCount
|
||||||
|
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
|
||||||
|
|
||||||
|
// ✅ Multiple conditions — use toPass
|
||||||
|
await expect(async () => {
|
||||||
|
expect(await node1.getValue()).toBe('foo')
|
||||||
|
expect(await node2.getValue()).toBe('bar')
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rule: Wait for the Real Readiness Boundary
|
||||||
|
|
||||||
|
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
|
||||||
|
|
||||||
|
Common readiness boundaries:
|
||||||
|
|
||||||
|
| After this action... | Wait for... |
|
||||||
|
| -------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
|
||||||
|
| Menu item click | `await contextMenu.waitForHidden()` |
|
||||||
|
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
|
||||||
|
| Settings write | Poll the setting value with `expect.poll()` |
|
||||||
|
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
|
||||||
|
| Graph mutation (add/remove node, link) | Poll link/node count |
|
||||||
|
| Clipboard write | Poll pasted value |
|
||||||
|
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
|
||||||
|
|
||||||
|
#### Rule: Expose Locators for Retrying Assertions
|
||||||
|
|
||||||
|
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Helper exposes locator
|
||||||
|
get domWidgets(): Locator {
|
||||||
|
return this.page.locator('.dom-widget')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller uses retrying assertion
|
||||||
|
await expect(comfyPage.domWidgets).toHaveCount(2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace count methods with locator getters so callers can use retrying assertions directly.
|
||||||
|
|
||||||
|
#### Rule: Fix Check-then-Act Races in Helpers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Race: count can change between check and waitFor
|
||||||
|
const count = await locator.count()
|
||||||
|
if (count > 0) {
|
||||||
|
await locator.waitFor({ state: 'hidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Direct: waitFor handles both cases
|
||||||
|
await locator.waitFor({ state: 'hidden' })
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rule: Remove force:true from Clicks
|
||||||
|
|
||||||
|
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Hides the race
|
||||||
|
await closeButton.click({ force: true })
|
||||||
|
|
||||||
|
// ✅ Surfaces the real issue — fix with proper wait
|
||||||
|
await closeButton.click()
|
||||||
|
await dialog.waitForHidden()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rule: Handle Non-deterministic Element Order
|
||||||
|
|
||||||
|
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Assumes order
|
||||||
|
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||||
|
|
||||||
|
// ✅ Find by ID or proximity
|
||||||
|
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||||
|
let target = nodes[0]
|
||||||
|
for (const n of nodes) {
|
||||||
|
const pos = await n.getPosition()
|
||||||
|
if (Math.abs(pos.y - expectedY) < minDist) target = n
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or guard the assumption:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
const node = nodes[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
|
||||||
|
|
||||||
|
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Retry click+assert together
|
||||||
|
await expect(async () => {
|
||||||
|
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
|
||||||
|
await expect(dialog).toBeHidden({ timeout: 500 })
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Keep Changes Narrow
|
||||||
|
|
||||||
|
- Shared helpers should drive setup to a stable boundary.
|
||||||
|
- Do not encode one-spec timing assumptions into generic helpers.
|
||||||
|
- If a race only matters to one spec, prefer a local wait in that spec.
|
||||||
|
- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness.
|
||||||
|
|
||||||
|
### 5. Verify Narrowly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Targeted rerun with repetition
|
||||||
|
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
|
||||||
|
|
||||||
|
# Single test by line number (avoids grep quoting issues on Windows)
|
||||||
|
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
|
||||||
|
- Verify with the smallest command that exercises the flaky path.
|
||||||
|
|
||||||
|
### 6. Watch CI E2E Runs
|
||||||
|
|
||||||
|
After pushing, use `gh` to monitor the E2E workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the run for the current branch
|
||||||
|
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
|
||||||
|
|
||||||
|
# Watch it live (blocks until complete, streams logs)
|
||||||
|
gh run watch <run-id>
|
||||||
|
|
||||||
|
# One-liner: find and watch the latest E2E run for the current branch
|
||||||
|
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# One-liner equivalent
|
||||||
|
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
|
||||||
|
```
|
||||||
|
|
||||||
|
After the run completes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download the Playwright report artifact
|
||||||
|
gh run download <run-id> -n playwright-report
|
||||||
|
|
||||||
|
# View the run summary in browser
|
||||||
|
gh run view <run-id> --web
|
||||||
|
```
|
||||||
|
|
||||||
|
Also watch the unit test workflow in parallel if you changed helpers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Pre-merge Checklist
|
||||||
|
|
||||||
|
Before merging a flaky-test fix, confirm:
|
||||||
|
|
||||||
|
- [ ] The latest CI artifact was inspected directly
|
||||||
|
- [ ] The root cause is stated as a race or readiness mismatch
|
||||||
|
- [ ] The fix waits on the real readiness boundary
|
||||||
|
- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass)
|
||||||
|
- [ ] The fix stays local unless a shared helper truly owns the race
|
||||||
|
- [ ] Local verification uses a targeted rerun
|
||||||
|
- [ ] No behavioral changes to the test — only timing/retry strategy updated
|
||||||
|
|
||||||
|
## Local Noise — Do Not Fix
|
||||||
|
|
||||||
|
These are local distractions, not CI root causes:
|
||||||
|
|
||||||
|
- Missing local input fixture files required by the test path
|
||||||
|
- Missing local models directory
|
||||||
|
- Teardown `EPERM` while restoring the local browser-test user data directory
|
||||||
|
- Local screenshot baseline differences on Windows
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- First confirm whether it blocks the exact flaky path under investigation.
|
||||||
|
- Do not commit temporary local assets used only for verification.
|
||||||
|
- Do not commit local screenshot baselines.
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
---
|
|
||||||
name: ticket-intake
|
|
||||||
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Ticket Intake
|
|
||||||
|
|
||||||
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
|
|
||||||
|
|
||||||
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
|
|
||||||
|
|
||||||
## Supported Sources
|
|
||||||
|
|
||||||
| Source | URL Pattern | Provider File |
|
|
||||||
| ------ | --------------------------------------------------- | --------------------- |
|
|
||||||
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
|
|
||||||
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
When given a ticket URL:
|
|
||||||
|
|
||||||
1. **Detect source type** from URL pattern
|
|
||||||
2. **Load provider-specific logic** from `providers/` directory
|
|
||||||
3. Fetch ticket content via appropriate API
|
|
||||||
4. Extract and normalize properties to common schema
|
|
||||||
5. **Register ticket in pipeline API** ← MANDATORY
|
|
||||||
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
|
|
||||||
7. **Run verification script** to confirm API registration
|
|
||||||
8. Output summary and handoff to `research-orchestrator`
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Uses the **production API** by default. No configuration needed for read operations.
|
|
||||||
|
|
||||||
**Defaults (no setup required):**
|
|
||||||
|
|
||||||
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
|
|
||||||
- Read-only endpoints at `/public/*` require no authentication
|
|
||||||
|
|
||||||
**For write operations** (transitions, creating tickets), set:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
|
|
||||||
```
|
|
||||||
|
|
||||||
**Optional (for local working artifacts):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mandatory API Calls (Execute ALL Three)
|
|
||||||
|
|
||||||
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
|
|
||||||
|
|
||||||
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
|
|
||||||
|
|
||||||
### Call 1: Create Ticket
|
|
||||||
|
|
||||||
```bash
|
|
||||||
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
|
|
||||||
API_KEY="${PIPELINE_API_KEY}"
|
|
||||||
|
|
||||||
curl -s -X POST "${API_URL}/v1/tickets" \
|
|
||||||
-H "Authorization: Bearer ${API_KEY}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
|
||||||
-d '{
|
|
||||||
"notion_page_id": "NOTION_PAGE_UUID_HERE",
|
|
||||||
"title": "TICKET_TITLE_HERE",
|
|
||||||
"source": "notion",
|
|
||||||
"metadata": {
|
|
||||||
"description": "DESCRIPTION_HERE",
|
|
||||||
"priority": "High",
|
|
||||||
"labels": [],
|
|
||||||
"acceptanceCriteria": []
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Save the returned `id` — you need it for the next two calls.
|
|
||||||
|
|
||||||
### Call 2: Transition to RESEARCH
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TICKET_ID="id-from-step-1"
|
|
||||||
|
|
||||||
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
|
|
||||||
-H "Authorization: Bearer ${API_KEY}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
|
||||||
-d '{
|
|
||||||
"to_state": "RESEARCH",
|
|
||||||
"reason": "Intake complete, starting research"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Call 3: Queue Source Update
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST "${API_URL}/v1/sync/queue" \
|
|
||||||
-H "Authorization: Bearer ${API_KEY}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
|
||||||
-d '{
|
|
||||||
"ticket_id": "TICKET_ID_HERE",
|
|
||||||
"action": "update_status",
|
|
||||||
"payload": { "status": "In Progress" },
|
|
||||||
"priority": "normal"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
|
|
||||||
|
|
||||||
### TypeScript Equivalent (if using pipeline client)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { PipelineClient } from '@pipeline/client'
|
|
||||||
|
|
||||||
const client = new PipelineClient({
|
|
||||||
apiUrl:
|
|
||||||
process.env.PIPELINE_API_URL ||
|
|
||||||
'https://api-gateway-856475788601.us-central1.run.app',
|
|
||||||
agentId: process.env.AGENT_ID!
|
|
||||||
})
|
|
||||||
|
|
||||||
const ticket = await client.createTicket({
|
|
||||||
notion_page_id: pageId,
|
|
||||||
title: ticketTitle,
|
|
||||||
source: 'notion',
|
|
||||||
metadata: { description, priority, labels, acceptanceCriteria }
|
|
||||||
})
|
|
||||||
|
|
||||||
await client.transitionState(
|
|
||||||
ticket.id,
|
|
||||||
'RESEARCH',
|
|
||||||
'Intake complete, starting research'
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
### Step 1: Detect Source Type
|
|
||||||
|
|
||||||
Parse the URL to determine source:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (url.includes('notion.so')) {
|
|
||||||
source = 'notion'
|
|
||||||
// Load providers/notion.md
|
|
||||||
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
|
|
||||||
source = 'github'
|
|
||||||
// Load providers/github.md
|
|
||||||
} else {
|
|
||||||
// Error: Unsupported URL format
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Load Provider and Fetch Data
|
|
||||||
|
|
||||||
Read the appropriate provider file for source-specific instructions:
|
|
||||||
|
|
||||||
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
|
|
||||||
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
|
|
||||||
|
|
||||||
Follow the provider's instructions for:
|
|
||||||
|
|
||||||
- Fetching content
|
|
||||||
- Extracting properties
|
|
||||||
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
|
|
||||||
|
|
||||||
### Step 3: Normalize to Common Schema
|
|
||||||
|
|
||||||
All providers must extract normalized ticket data following `schema.md`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "abc12345",
|
|
||||||
"url": "https://...",
|
|
||||||
"source": "notion | github",
|
|
||||||
"title": "Ticket title",
|
|
||||||
"description": "Full description",
|
|
||||||
"status": "Not Started",
|
|
||||||
"assignee": "username",
|
|
||||||
"priority": "High",
|
|
||||||
"area": "UI",
|
|
||||||
"labels": ["bug", "frontend"],
|
|
||||||
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
|
|
||||||
"fetchedAt": "2024-01-15T10:30:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
|
|
||||||
|
|
||||||
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
|
|
||||||
|
|
||||||
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
|
|
||||||
|
|
||||||
1. `createTicket()` → save the returned ticket ID
|
|
||||||
2. `transitionState(id, 'RESEARCH')` → confirm state changed
|
|
||||||
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
|
|
||||||
|
|
||||||
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
|
|
||||||
|
|
||||||
### Step 5: Run Verification Script
|
|
||||||
|
|
||||||
After making the API calls, run the verification script to confirm everything worked:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
**If the script is not available locally**, verify manually via the public API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "...",
|
|
||||||
"state": "RESEARCH",
|
|
||||||
"title": "...",
|
|
||||||
"notion_page_id": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
|
|
||||||
|
|
||||||
### Step 6: Output Summary and Handoff
|
|
||||||
|
|
||||||
Print a clear summary:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Ticket Intake Complete
|
|
||||||
|
|
||||||
**Source:** Notion | GitHub
|
|
||||||
**Title:** [Ticket title]
|
|
||||||
**ID:** abc12345
|
|
||||||
**Status:** In Progress (queued)
|
|
||||||
**Priority:** High
|
|
||||||
**Area:** UI
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
[Brief description or first 200 chars]
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] Criterion 1
|
|
||||||
- [ ] Criterion 2
|
|
||||||
|
|
||||||
### Links
|
|
||||||
|
|
||||||
- **Ticket:** [Original URL]
|
|
||||||
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
|
|
||||||
|
|
||||||
### Pipeline
|
|
||||||
|
|
||||||
- **API Ticket ID:** abc12345
|
|
||||||
- **State:** RESEARCH
|
|
||||||
- **Verified:** ✅ (via verify-intake.sh or public API)
|
|
||||||
```
|
|
||||||
|
|
||||||
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
|
|
||||||
|
|
||||||
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
|
|
||||||
|
|
||||||
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Unsupported URL
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ Unsupported ticket URL format.
|
|
||||||
|
|
||||||
Supported formats:
|
|
||||||
- Notion: https://notion.so/... or https://www.notion.so/...
|
|
||||||
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
|
|
||||||
|
|
||||||
Received: [provided URL]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Provider-Specific Errors
|
|
||||||
|
|
||||||
See individual provider files for source-specific error handling:
|
|
||||||
|
|
||||||
- `providers/notion.md` - Authentication, page not found
|
|
||||||
- `providers/github.md` - Auth, rate limits, issue not found
|
|
||||||
|
|
||||||
### Missing Properties
|
|
||||||
|
|
||||||
Continue with available data and note what's missing:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ Some properties unavailable:
|
|
||||||
- Priority: not found (using default: Medium)
|
|
||||||
- Area: not found
|
|
||||||
|
|
||||||
Proceeding with available data...
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Call Failures
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ Pipeline API call failed: {method} {endpoint}
|
|
||||||
Status: {status}
|
|
||||||
Error: {message}
|
|
||||||
|
|
||||||
Retrying once...
|
|
||||||
|
|
||||||
❌ Retry also failed. INTAKE IS INCOMPLETE.
|
|
||||||
The ticket was NOT registered in the pipeline.
|
|
||||||
Downstream skills will not work until this is fixed.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This skill focuses ONLY on intake — it does not do research
|
|
||||||
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
|
|
||||||
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
|
|
||||||
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
|
|
||||||
- Pipeline state is tracked via the API, not local files
|
|
||||||
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
|
|
||||||
- The `source` field in the ticket determines which research strategies to use
|
|
||||||
|
|
||||||
## API Client Reference
|
|
||||||
|
|
||||||
### Available Methods
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
||||||
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
|
|
||||||
| `getTicket(id)` | Retrieve a ticket by ID |
|
|
||||||
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
|
|
||||||
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
|
|
||||||
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
|
|
||||||
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
|
|
||||||
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
|
|
||||||
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.createTicket({ ... });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PipelineAPIError) {
|
|
||||||
console.error(`API Error ${error.status}: ${error.message}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
# GitHub Provider - Ticket Intake
|
|
||||||
|
|
||||||
Provider-specific logic for ingesting tickets from GitHub Issues.
|
|
||||||
|
|
||||||
## URL Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
https://github.com/{owner}/{repo}/issues/{number}
|
|
||||||
https://www.github.com/{owner}/{repo}/issues/{number}
|
|
||||||
```
|
|
||||||
|
|
||||||
Extract: `owner`, `repo`, `issue_number` from URL.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- `gh` CLI authenticated (`gh auth status`)
|
|
||||||
- Access to the repository
|
|
||||||
|
|
||||||
## Fetch Issue Content
|
|
||||||
|
|
||||||
Use `gh` CLI to fetch issue details:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get issue details in JSON
|
|
||||||
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
|
|
||||||
|
|
||||||
# Get comments separately if needed
|
|
||||||
gh issue view {number} --repo {owner}/{repo} --comments
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extract Ticket Data
|
|
||||||
|
|
||||||
Map GitHub issue fields to normalized ticket data (stored via API):
|
|
||||||
|
|
||||||
| GitHub Field | ticket.json Field | Notes |
|
|
||||||
| ------------ | ----------------- | -------------------------- |
|
|
||||||
| title | title | Direct mapping |
|
|
||||||
| body | description | Issue body/description |
|
|
||||||
| state | status | Map: open → "Not Started" |
|
|
||||||
| labels | labels | Array of label names |
|
|
||||||
| assignees | assignee | First assignee login |
|
|
||||||
| author | author | Issue author login |
|
|
||||||
| milestone | milestone | Milestone title if present |
|
|
||||||
| comments | comments | Array of comment objects |
|
|
||||||
| linkedPRs | linkedPRs | PRs linked to this issue |
|
|
||||||
|
|
||||||
### Priority Mapping
|
|
||||||
|
|
||||||
Infer priority from labels:
|
|
||||||
|
|
||||||
- `priority:critical`, `P0` → "Critical"
|
|
||||||
- `priority:high`, `P1` → "High"
|
|
||||||
- `priority:medium`, `P2` → "Medium"
|
|
||||||
- `priority:low`, `P3` → "Low"
|
|
||||||
- No priority label → "Medium" (default)
|
|
||||||
|
|
||||||
### Area Mapping
|
|
||||||
|
|
||||||
Infer area from labels:
|
|
||||||
|
|
||||||
- `area:ui`, `frontend`, `component:*` → "UI"
|
|
||||||
- `area:api`, `backend` → "API"
|
|
||||||
- `area:docs`, `documentation` → "Docs"
|
|
||||||
- `bug`, `fix` → "Bug"
|
|
||||||
- `enhancement`, `feature` → "Feature"
|
|
||||||
|
|
||||||
## Update Source
|
|
||||||
|
|
||||||
**For GitHub issues, update is optional but recommended.**
|
|
||||||
|
|
||||||
Add a comment to indicate work has started:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
|
|
||||||
```
|
|
||||||
|
|
||||||
Optionally assign to self:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
|
|
||||||
```
|
|
||||||
|
|
||||||
Log any updates via the Pipeline API:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.updateTicket(ticketId, {
|
|
||||||
metadata: {
|
|
||||||
...ticket.metadata,
|
|
||||||
githubWrites: [
|
|
||||||
...(ticket.metadata?.githubWrites || []),
|
|
||||||
{
|
|
||||||
action: 'comment',
|
|
||||||
issueNumber: 123,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
skill: 'ticket-intake',
|
|
||||||
success: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## GitHub-Specific Ticket Fields
|
|
||||||
|
|
||||||
Store via API using `client.createTicket()`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source": "github",
|
|
||||||
"githubOwner": "Comfy-Org",
|
|
||||||
"githubRepo": "ComfyUI_frontend",
|
|
||||||
"githubIssueNumber": 123,
|
|
||||||
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
|
|
||||||
"labels": ["bug", "area:ui", "priority:high"],
|
|
||||||
"linkedPRs": [456, 789],
|
|
||||||
"dosuComment": "..." // Extracted Dosu bot analysis if present
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dosu Bot Detection
|
|
||||||
|
|
||||||
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
|
|
||||||
```
|
|
||||||
|
|
||||||
Look for comments from:
|
|
||||||
|
|
||||||
- `dosu[bot]`
|
|
||||||
- `dosu-bot`
|
|
||||||
|
|
||||||
Extract Dosu analysis which typically includes:
|
|
||||||
|
|
||||||
- Root cause analysis
|
|
||||||
- Suggested files to modify
|
|
||||||
- Related issues/PRs
|
|
||||||
- Potential solutions
|
|
||||||
|
|
||||||
Store in ticket data via API:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dosuComment": {
|
|
||||||
"found": true,
|
|
||||||
"analysis": "...",
|
|
||||||
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
|
|
||||||
"relatedIssues": [100, 101]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extract Linked Issues/PRs
|
|
||||||
|
|
||||||
Parse issue body and comments for references:
|
|
||||||
|
|
||||||
- `#123` → Issue or PR reference
|
|
||||||
- `fixes #123`, `closes #123` → Linked issue
|
|
||||||
- `https://github.com/.../issues/123` → Full URL reference
|
|
||||||
|
|
||||||
Store in ticket data via API for research phase:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"referencedIssues": [100, 101, 102],
|
|
||||||
"referencedPRs": [200, 201]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Authentication Error
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ GitHub CLI not authenticated.
|
|
||||||
Run: gh auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue Not Found
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ GitHub issue not found or inaccessible.
|
|
||||||
- Check the URL is correct
|
|
||||||
- Ensure you have access to this repository
|
|
||||||
- Run: gh auth status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rate Limiting
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ GitHub API rate limited.
|
|
||||||
Wait a few minutes and try again.
|
|
||||||
Check status: gh api rate_limit
|
|
||||||
```
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# Notion Provider - Ticket Intake
|
|
||||||
|
|
||||||
Provider-specific logic for ingesting tickets from Notion.
|
|
||||||
|
|
||||||
## URL Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
https://www.notion.so/workspace/Page-Title-abc123def456...
|
|
||||||
https://notion.so/Page-Title-abc123def456...
|
|
||||||
https://www.notion.so/abc123def456...
|
|
||||||
```
|
|
||||||
|
|
||||||
Page ID is the 32-character hex string (with or without hyphens).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Notion MCP connected and authenticated
|
|
||||||
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
|
|
||||||
- Authenticate via `/mcp` command if prompted
|
|
||||||
|
|
||||||
## Fetch Ticket Content
|
|
||||||
|
|
||||||
Use `Notion:notion-fetch` with the page URL or ID:
|
|
||||||
|
|
||||||
```
|
|
||||||
Fetch the full page content including all properties
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extract Ticket Data
|
|
||||||
|
|
||||||
Extract these properties (names may vary):
|
|
||||||
|
|
||||||
| Property | Expected Name | Type |
|
|
||||||
| ------------- | ------------------------- | ------------ |
|
|
||||||
| Title | Name / Title | Title |
|
|
||||||
| Status | Status | Select |
|
|
||||||
| Assignee | Assignee / Assigned To | Person |
|
|
||||||
| Description | - | Page content |
|
|
||||||
| Slack Link | Slack Link / Slack Thread | URL |
|
|
||||||
| GitHub PR | GitHub PR / PR Link | URL |
|
|
||||||
| Priority | Priority | Select |
|
|
||||||
| Area | Area / Category | Select |
|
|
||||||
| Related Tasks | Related Tasks | Relation |
|
|
||||||
|
|
||||||
**If properties are missing**: Note what's unavailable and continue with available data.
|
|
||||||
|
|
||||||
## Update Source (REQUIRED)
|
|
||||||
|
|
||||||
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
|
|
||||||
|
|
||||||
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
|
|
||||||
|
|
||||||
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
|
|
||||||
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
|
|
||||||
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
|
|
||||||
|
|
||||||
Use `Notion:notion-update-page` to update the ticket:
|
|
||||||
|
|
||||||
1. **Status**: Set to "In Progress" (only valid from "Not Started")
|
|
||||||
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"page_id": "{page_id_from_ticket}",
|
|
||||||
"command": "update_properties",
|
|
||||||
"properties": {
|
|
||||||
"Status": "In Progress",
|
|
||||||
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After the update succeeds**, log the write via the Pipeline API:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.updateTicket(ticketId, {
|
|
||||||
metadata: {
|
|
||||||
...ticket.metadata,
|
|
||||||
notionWrites: [
|
|
||||||
...(ticket.metadata?.notionWrites || []),
|
|
||||||
{
|
|
||||||
field: 'Status',
|
|
||||||
value: 'In Progress',
|
|
||||||
previousValue: 'Not Started',
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
skill: 'ticket-intake',
|
|
||||||
success: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
If update fails, log with `success: false` and continue.
|
|
||||||
|
|
||||||
## Notion-Specific Ticket Fields
|
|
||||||
|
|
||||||
Store via API using `client.createTicket()`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source": "notion",
|
|
||||||
"notionPageId": "abc123def456...",
|
|
||||||
"slackLink": "https://slack.com/...",
|
|
||||||
"relatedTasks": ["page-id-1", "page-id-2"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Slack Thread Handling
|
|
||||||
|
|
||||||
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
|
|
||||||
|
|
||||||
### Slack URL Conversion
|
|
||||||
|
|
||||||
Notion stores Slack links in `slackMessage://` format:
|
|
||||||
|
|
||||||
```
|
|
||||||
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
|
|
||||||
```
|
|
||||||
|
|
||||||
Convert to browser-clickable format:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
|
|
||||||
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
|
|
||||||
|
|
||||||
(Remove the dot from the last timestamp and prefix with `p`)
|
|
||||||
|
|
||||||
### Fetching Thread Content
|
|
||||||
|
|
||||||
Load the `slackdump` skill and use the **export-thread** workflow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Export thread by URL
|
|
||||||
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
|
|
||||||
|
|
||||||
# Or by colon notation (channel_id:thread_ts)
|
|
||||||
slackdump dump CHANNEL_ID:THREAD_TS
|
|
||||||
```
|
|
||||||
|
|
||||||
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
|
|
||||||
|
|
||||||
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
|
|
||||||
|
|
||||||
## Database Reference: Comfy Tasks
|
|
||||||
|
|
||||||
The "Comfy Tasks" database has these properties (verify via `notion-search`):
|
|
||||||
|
|
||||||
- **Status values**: Not Started, In Progress, In Review, Done
|
|
||||||
- **Team assignment**: "Frontend Team" for unassigned tickets
|
|
||||||
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
|
|
||||||
|
|
||||||
### Pipeline Owner Details
|
|
||||||
|
|
||||||
When assigning tickets, use these identifiers:
|
|
||||||
|
|
||||||
| Platform | Identifier |
|
|
||||||
| --------------- | -------------------------------------- |
|
|
||||||
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
|
|
||||||
| Notion Name | Christian Byrne |
|
|
||||||
| Notion Email | cbyrne@comfy.org |
|
|
||||||
| Slack User ID | U087MJCDHHC |
|
|
||||||
| GitHub Username | christian-byrne |
|
|
||||||
|
|
||||||
**To update Assignee**, use the Notion User ID (not name):
|
|
||||||
|
|
||||||
```
|
|
||||||
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Finding Active Tickets
|
|
||||||
|
|
||||||
To list your active tickets:
|
|
||||||
|
|
||||||
```
|
|
||||||
Use Notion:notion-search for "Comfy Tasks"
|
|
||||||
Filter by Assignee = current user OR Team = "Frontend Team"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Authentication Error
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ Notion authentication required.
|
|
||||||
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
|
|
||||||
Then authenticate via /mcp command.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Page Not Found
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ Notion page not found or inaccessible.
|
|
||||||
- Check the URL is correct
|
|
||||||
- Ensure you have access to this page
|
|
||||||
- Try re-authenticating via /mcp
|
|
||||||
```
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# Ticket Schema
|
|
||||||
|
|
||||||
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
|
|
||||||
|
|
||||||
## Ticket Data Schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
// Required fields (all sources)
|
|
||||||
"id": "string", // Unique identifier (short form)
|
|
||||||
"url": "string", // Original URL
|
|
||||||
"source": "notion | github", // Source type
|
|
||||||
"title": "string", // Ticket title
|
|
||||||
"description": "string", // Full description/body
|
|
||||||
"fetchedAt": "ISO8601", // When ticket was fetched
|
|
||||||
|
|
||||||
// Common optional fields
|
|
||||||
"status": "string", // Current status
|
|
||||||
"assignee": "string", // Assigned user
|
|
||||||
"priority": "string", // Priority level
|
|
||||||
"area": "string", // Category/area
|
|
||||||
"labels": ["string"], // Tags/labels
|
|
||||||
"acceptanceCriteria": ["string"] // List of AC items
|
|
||||||
|
|
||||||
// Source-specific fields (see providers)
|
|
||||||
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
|
|
||||||
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ticket State Schema (via API)
|
|
||||||
|
|
||||||
State is managed via the Pipeline API using `client.transitionState()`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ticketId": "string",
|
|
||||||
"state": "intake | research | planning | implementation | pr_created | done | failed",
|
|
||||||
"stateChangedAt": "ISO8601",
|
|
||||||
|
|
||||||
// Timestamps tracked by API
|
|
||||||
"createdAt": "ISO8601",
|
|
||||||
"updatedAt": "ISO8601"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Priority Normalization
|
|
||||||
|
|
||||||
All sources should normalize to these values:
|
|
||||||
|
|
||||||
| Normalized | Description |
|
|
||||||
| ---------- | ------------------------- |
|
|
||||||
| Critical | Production down, security |
|
|
||||||
| High | Blocking work, urgent |
|
|
||||||
| Medium | Normal priority (default) |
|
|
||||||
| Low | Nice to have, backlog |
|
|
||||||
|
|
||||||
## Status Normalization
|
|
||||||
|
|
||||||
Pipeline tracks these statuses internally:
|
|
||||||
|
|
||||||
| Status | Description |
|
|
||||||
| -------------- | ---------------------------- |
|
|
||||||
| research | Gathering context |
|
|
||||||
| planning | Creating implementation plan |
|
|
||||||
| implementation | Writing code |
|
|
||||||
| review | Code review in progress |
|
|
||||||
| qa | Quality assurance |
|
|
||||||
| done | PR merged or completed |
|
|
||||||
|
|
||||||
## ID Generation
|
|
||||||
|
|
||||||
IDs are generated by the API when creating tickets. For reference:
|
|
||||||
|
|
||||||
- **Notion**: First 8 characters of page ID
|
|
||||||
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Notion: `abc12345`
|
|
||||||
- GitHub: `gh-comfy-org-frontend-123`
|
|
||||||
31
.github/actions/lint-format-verify/action.yml
vendored
Normal file
31
.github/actions/lint-format-verify/action.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: 'Lint and format verify'
|
||||||
|
description: >
|
||||||
|
Runs the lint/format/knip verification suite plus a conditional
|
||||||
|
browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and
|
||||||
|
ci-lint-format-queue.yaml (merge queue) so both paths run the exact
|
||||||
|
same checks. The caller is responsible for checkout and frontend setup
|
||||||
|
before invoking this action.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Detect browser_tests changes
|
||||||
|
id: changed-paths
|
||||||
|
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
browser_tests:
|
||||||
|
- 'browser_tests/**'
|
||||||
|
|
||||||
|
- name: Verify lint and format
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
pnpm lint
|
||||||
|
pnpm stylelint
|
||||||
|
pnpm format:check
|
||||||
|
pnpm knip
|
||||||
|
|
||||||
|
- name: Typecheck browser tests
|
||||||
|
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: pnpm typecheck:browser
|
||||||
29
.github/workflows/ci-lint-format-queue.yaml
vendored
Normal file
29
.github/workflows/ci-lint-format-queue.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Description: Lint and format verification for GitHub merge queue runs.
|
||||||
|
# Paired with ci-lint-format.yaml — workflow name and job name must match
|
||||||
|
# so branch protection resolves a single required check in both the
|
||||||
|
# pull_request and merge_group contexts. This file runs verify-only steps
|
||||||
|
# with a read-only token; auto-fix and PR comments live in the PR workflow.
|
||||||
|
name: 'CI: Lint Format'
|
||||||
|
|
||||||
|
on:
|
||||||
|
merge_group:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout merge group ref
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup frontend
|
||||||
|
uses: ./.github/actions/setup-frontend
|
||||||
|
|
||||||
|
- name: Verify lint and format
|
||||||
|
uses: ./.github/actions/lint-format-verify
|
||||||
25
.github/workflows/ci-lint-format.yaml
vendored
25
.github/workflows/ci-lint-format.yaml
vendored
@@ -1,4 +1,7 @@
|
|||||||
# Description: Linting and code formatting validation for pull requests
|
# Description: Linting and code formatting validation for pull requests.
|
||||||
|
# Paired with ci-lint-format-queue.yaml - workflow name and job name must
|
||||||
|
# match so branch protection resolves a single required check in both the
|
||||||
|
# pull_request and merge_group contexts.
|
||||||
name: 'CI: Lint Format'
|
name: 'CI: Lint Format'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -26,14 +29,6 @@ jobs:
|
|||||||
- name: Setup frontend
|
- name: Setup frontend
|
||||||
uses: ./.github/actions/setup-frontend
|
uses: ./.github/actions/setup-frontend
|
||||||
|
|
||||||
- name: Detect browser_tests changes
|
|
||||||
id: changed-paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
browser_tests:
|
|
||||||
- 'browser_tests/**'
|
|
||||||
|
|
||||||
- name: Run ESLint with auto-fix
|
- name: Run ESLint with auto-fix
|
||||||
run: pnpm lint:fix
|
run: pnpm lint:fix
|
||||||
|
|
||||||
@@ -77,16 +72,8 @@ jobs:
|
|||||||
echo "See CONTRIBUTING.md for more details."
|
echo "See CONTRIBUTING.md for more details."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Final validation
|
- name: Verify lint and format
|
||||||
run: |
|
uses: ./.github/actions/lint-format-verify
|
||||||
pnpm lint
|
|
||||||
pnpm stylelint
|
|
||||||
pnpm format:check
|
|
||||||
pnpm knip
|
|
||||||
|
|
||||||
- name: Typecheck browser tests
|
|
||||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
|
||||||
run: pnpm typecheck:browser
|
|
||||||
|
|
||||||
- name: Comment on PR about auto-fix
|
- name: Comment on PR about auto-fix
|
||||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
|||||||
1
.github/workflows/ci-tests-e2e.yaml
vendored
1
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -8,6 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches-ignore: [wip/*, draft/*, temp/*]
|
branches-ignore: [wip/*, draft/*, temp/*]
|
||||||
paths-ignore: ['**/*.md']
|
paths-ignore: ['**/*.md']
|
||||||
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
1
.github/workflows/ci-tests-unit.yaml
vendored
1
.github/workflows/ci-tests-unit.yaml
vendored
@@ -8,6 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches-ignore: [wip/*, draft/*, temp/*]
|
branches-ignore: [wip/*, draft/*, temp/*]
|
||||||
paths-ignore: ['**/*.md']
|
paths-ignore: ['**/*.md']
|
||||||
|
merge_group:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"typescript/no-unsafe-declaration-merging": "off",
|
"typescript/no-unsafe-declaration-merging": "off",
|
||||||
"typescript/no-unused-vars": "off",
|
"typescript/no-unused-vars": "off",
|
||||||
"unicorn/no-empty-file": "off",
|
"unicorn/no-empty-file": "off",
|
||||||
|
"vitest/require-mock-type-parameters": "off",
|
||||||
"unicorn/no-new-array": "off",
|
"unicorn/no-new-array": "off",
|
||||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||||
"unicorn/no-useless-fallback-in-spread": "off",
|
"unicorn/no-useless-fallback-in-spread": "off",
|
||||||
@@ -116,13 +117,60 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["browser_tests/**/*.ts"],
|
"files": ["browser_tests/**/*.ts"],
|
||||||
|
"jsPlugins": ["eslint-plugin-playwright"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"typescript/no-explicit-any": "error",
|
"typescript/no-explicit-any": "error",
|
||||||
"no-async-promise-executor": "error",
|
"no-async-promise-executor": "error",
|
||||||
"no-control-regex": "error",
|
"no-control-regex": "error",
|
||||||
"no-useless-rename": "error",
|
"no-useless-rename": "error",
|
||||||
"no-unused-private-class-members": "error",
|
"no-unused-private-class-members": "error",
|
||||||
"unicorn/no-empty-file": "error"
|
"unicorn/no-empty-file": "error",
|
||||||
|
"playwright/consistent-spacing-between-blocks": "error",
|
||||||
|
"playwright/expect-expect": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"assertFunctionNames": [
|
||||||
|
"recordMeasurement",
|
||||||
|
"logMeasurement",
|
||||||
|
"builderSaveAs"
|
||||||
|
],
|
||||||
|
"assertFunctionPatterns": [
|
||||||
|
"^expect",
|
||||||
|
"^assert",
|
||||||
|
"^verify",
|
||||||
|
"^searchAndExpect",
|
||||||
|
"waitForOpen",
|
||||||
|
"waitForClosed",
|
||||||
|
"waitForRequest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"playwright/max-nested-describe": "error",
|
||||||
|
"playwright/no-duplicate-hooks": "error",
|
||||||
|
"playwright/no-element-handle": "error",
|
||||||
|
"playwright/no-eval": "error",
|
||||||
|
"playwright/no-focused-test": "error",
|
||||||
|
"playwright/no-force-option": "error",
|
||||||
|
"playwright/no-networkidle": "error",
|
||||||
|
"playwright/no-page-pause": "error",
|
||||||
|
"playwright/no-skipped-test": "error",
|
||||||
|
"playwright/no-unsafe-references": "error",
|
||||||
|
"playwright/no-unused-locators": "error",
|
||||||
|
"playwright/no-useless-await": "error",
|
||||||
|
"playwright/no-useless-not": "error",
|
||||||
|
"playwright/no-wait-for-navigation": "error",
|
||||||
|
"playwright/no-wait-for-selector": "error",
|
||||||
|
"playwright/no-wait-for-timeout": "error",
|
||||||
|
"playwright/prefer-hooks-on-top": "error",
|
||||||
|
"playwright/prefer-locator": "error",
|
||||||
|
"playwright/prefer-to-have-count": "error",
|
||||||
|
"playwright/prefer-to-have-length": "error",
|
||||||
|
"playwright/prefer-web-first-assertions": "error",
|
||||||
|
"playwright/prefer-native-locators": "error",
|
||||||
|
"playwright/require-to-pass-timeout": "error",
|
||||||
|
"playwright/valid-expect": "error",
|
||||||
|
"playwright/valid-expect-in-promise": "error",
|
||||||
|
"playwright/valid-title": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
|
|||||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||||
|
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
|
||||||
|
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
|
||||||
|
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
|
||||||
|
|
||||||
## Agent-only rules
|
## Agent-only rules
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const features = computed(() => [
|
|||||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||||
<!-- Badge -->
|
<!-- Badge -->
|
||||||
<span
|
<span
|
||||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs tracking-widest text-brand-yellow uppercase"
|
||||||
>
|
>
|
||||||
{{ t('academy.badge', locale) }}
|
{{ t('academy.badge', locale) }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const steps = computed(() => [
|
|||||||
<!-- Connecting line between steps (desktop only) -->
|
<!-- Connecting line between steps (desktop only) -->
|
||||||
<div
|
<div
|
||||||
v-if="index < steps.length - 1"
|
v-if="index < steps.length - 1"
|
||||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
class="absolute top-8 right-0 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ const ctaButtons = computed(() => [
|
|||||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
class="size-64 rounded-full border-40 border-brand-yellow md:h-112 md:w-md md:border-64 lg:h-144 lg:w-xl lg:border-80"
|
||||||
>
|
>
|
||||||
<!-- Gap on the right side to form "C" shape -->
|
<!-- Gap on the right side to form "C" shape -->
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
class="absolute top-1/2 right-0 h-32 w-24 translate-x-1/2 -translate-y-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +44,7 @@ const ctaButtons = computed(() => [
|
|||||||
<!-- Right: Text content -->
|
<!-- Right: Text content -->
|
||||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||||
<h1
|
<h1
|
||||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
class="text-5xl/tight font-bold tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||||
>
|
>
|
||||||
{{ t('hero.headline', locale) }}
|
{{ t('hero.headline', locale) }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
|||||||
{{ t('manifesto.heading', locale) }}
|
{{ t('manifesto.heading', locale) }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
<p class="mx-auto mt-6 max-w-2xl text-lg/relaxed text-smoke-700">
|
||||||
{{ t('manifesto.body', locale) }}
|
{{ t('manifesto.body', locale) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ const features = computed(() => [
|
|||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<!-- Play button triangle -->
|
<!-- Play button triangle -->
|
||||||
<div
|
<div
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
class="flex size-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
class="ml-1 size-0 border-y-8 border-l-14 border-y-transparent border-l-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-smoke-700">
|
<p class="text-sm text-smoke-700">
|
||||||
@@ -54,7 +54,7 @@ const features = computed(() => [
|
|||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
class="size-2 rounded-full bg-brand-yellow"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const metrics = computed(() => [
|
|||||||
<div class="mx-auto max-w-7xl px-6">
|
<div class="mx-auto max-w-7xl px-6">
|
||||||
<!-- Heading -->
|
<!-- Heading -->
|
||||||
<p
|
<p
|
||||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
class="text-center text-xs font-medium tracking-widest text-smoke-700 uppercase"
|
||||||
>
|
>
|
||||||
{{ t('social.heading', locale) }}
|
{{ t('social.heading', locale) }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const filteredTestimonials = computed(() => {
|
|||||||
:key="testimonial.name"
|
:key="testimonial.name"
|
||||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||||
>
|
>
|
||||||
<blockquote class="text-base italic text-white">
|
<blockquote class="text-base text-white italic">
|
||||||
“{{ testimonial.quote }}”
|
“{{ testimonial.quote }}”
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ const activeCategory = ref(0)
|
|||||||
<!-- Left placeholder image (desktop only) -->
|
<!-- Left placeholder image (desktop only) -->
|
||||||
<div class="hidden flex-1 lg:block">
|
<div class="hidden flex-1 lg:block">
|
||||||
<div
|
<div
|
||||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
class="aspect-2/3 rounded-full border border-white/10 bg-charcoal-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center content -->
|
<!-- Center content -->
|
||||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
<div class="flex flex-col items-center text-center lg:flex-2">
|
||||||
<h2 class="text-3xl font-bold text-white">
|
<h2 class="text-3xl font-bold text-white">
|
||||||
{{ t('useCase.heading', locale) }}
|
{{ t('useCase.heading', locale) }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -70,7 +70,7 @@ const activeCategory = ref(0)
|
|||||||
<!-- Right placeholder image (desktop only) -->
|
<!-- Right placeholder image (desktop only) -->
|
||||||
<div class="hidden flex-1 lg:block">
|
<div class="hidden flex-1 lg:block">
|
||||||
<div
|
<div
|
||||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
class="aspect-2/3 rounded-3xl border border-white/10 bg-charcoal-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const pillars = computed(() => [
|
|||||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
class="flex size-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||||
>
|
>
|
||||||
{{ pillar.icon }}
|
{{ pillar.icon }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,39 @@ browser_tests/
|
|||||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||||
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
||||||
|
|
||||||
|
## Page Object Locator Style
|
||||||
|
|
||||||
|
Define UI element locators as `public readonly` properties assigned in the constructor — not as getter methods. Getters that simply return a locator add unnecessary indirection and hide the object shape from IDE auto-complete.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct — public readonly, assigned in constructor
|
||||||
|
export class MyDialog extends BaseDialog {
|
||||||
|
public readonly submitButton: Locator
|
||||||
|
public readonly cancelButton: Locator
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
super(page)
|
||||||
|
this.submitButton = this.root.getByRole('button', { name: 'Submit' })
|
||||||
|
this.cancelButton = this.root.getByRole('button', { name: 'Cancel' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Avoid — getter-based locators
|
||||||
|
export class MyDialog extends BaseDialog {
|
||||||
|
get submitButton() {
|
||||||
|
return this.root.getByRole('button', { name: 'Submit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keep as getters only when:**
|
||||||
|
|
||||||
|
- Lazy initialization is needed (`this._tab ??= new Tab(this.page)`)
|
||||||
|
- The value is computed from runtime state (e.g. `get id() { return this.userIds[index] }`)
|
||||||
|
- It's a private convenience accessor (e.g. `private get page() { return this.comfyPage.page }`)
|
||||||
|
|
||||||
|
When a class has cached locator properties, prefer reusing them in methods rather than rebuilding locators from scratch.
|
||||||
|
|
||||||
## Polling Assertions
|
## Polling Assertions
|
||||||
|
|
||||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||||
|
|||||||
@@ -83,6 +83,21 @@ await expect
|
|||||||
a different reproduction pattern.
|
a different reproduction pattern.
|
||||||
- Verify with the smallest command that exercises the flaky path.
|
- Verify with the smallest command that exercises the flaky path.
|
||||||
|
|
||||||
|
## 7. Common Flake Patterns
|
||||||
|
|
||||||
|
| Pattern | Bad | Fix |
|
||||||
|
| ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||||
|
| **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width).toBe(w)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width)).toBe(w)` |
|
||||||
|
| **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||||
|
| **Fake readiness helper** | Helper that clicks but doesn't assert state | Remove; poll the actual value |
|
||||||
|
| **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||||
|
| **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000ms; prefer default (5000ms) |
|
||||||
|
| **Immediate count()** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||||
|
| **Immediate evaluate after mutation** | `setSetting(); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||||
|
| **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||||
|
| **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||||
|
|
||||||
## Current Local Noise
|
## Current Local Noise
|
||||||
|
|
||||||
These are local distractions, not automatic CI root causes:
|
These are local distractions, not automatic CI root causes:
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ Most common testing needs are already addressed by these helpers, which will mak
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Prefer this:
|
// Prefer this:
|
||||||
expect(await node.isPinned()).toBe(true)
|
await expect.poll(() => node.isPinned()).toBe(true)
|
||||||
expect(await node.getProperty('title')).toBe('Expected Title')
|
await expect.poll(() => node.getProperty('title')).toBe('Expected Title')
|
||||||
|
|
||||||
// Over this - only use when needed:
|
// Over this - only use when needed:
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('state.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('state.png')
|
||||||
|
|||||||
40
browser_tests/assets/cube.obj
Normal file
40
browser_tests/assets/cube.obj
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Blender 5.2.0 Alpha
|
||||||
|
# www.blender.org
|
||||||
|
mtllib Untitled.mtl
|
||||||
|
o Cube
|
||||||
|
v 2.857396 2.486626 -0.081892
|
||||||
|
v 2.857396 0.486626 -0.081892
|
||||||
|
v 2.857396 2.486626 1.918108
|
||||||
|
v 2.857396 0.486626 1.918108
|
||||||
|
v 0.857396 2.486626 -0.081892
|
||||||
|
v 0.857396 0.486626 -0.081892
|
||||||
|
v 0.857396 2.486626 1.918108
|
||||||
|
v 0.857396 0.486626 1.918108
|
||||||
|
vn -0.0000 1.0000 -0.0000
|
||||||
|
vn -0.0000 -0.0000 1.0000
|
||||||
|
vn -1.0000 -0.0000 -0.0000
|
||||||
|
vn -0.0000 -1.0000 -0.0000
|
||||||
|
vn 1.0000 -0.0000 -0.0000
|
||||||
|
vn -0.0000 -0.0000 -1.0000
|
||||||
|
vt 0.625000 0.500000
|
||||||
|
vt 0.875000 0.500000
|
||||||
|
vt 0.875000 0.750000
|
||||||
|
vt 0.625000 0.750000
|
||||||
|
vt 0.375000 0.750000
|
||||||
|
vt 0.625000 1.000000
|
||||||
|
vt 0.375000 1.000000
|
||||||
|
vt 0.375000 0.000000
|
||||||
|
vt 0.625000 0.000000
|
||||||
|
vt 0.625000 0.250000
|
||||||
|
vt 0.375000 0.250000
|
||||||
|
vt 0.125000 0.500000
|
||||||
|
vt 0.375000 0.500000
|
||||||
|
vt 0.125000 0.750000
|
||||||
|
s 0
|
||||||
|
usemtl Material
|
||||||
|
f 1/1/1 5/2/1 7/3/1 3/4/1
|
||||||
|
f 4/5/2 3/4/2 7/6/2 8/7/2
|
||||||
|
f 8/8/3 7/9/3 5/10/3 6/11/3
|
||||||
|
f 6/12/4 2/13/4 4/5/4 8/14/4
|
||||||
|
f 2/13/5 1/1/5 3/4/5 4/5/5
|
||||||
|
f 6/11/6 5/10/6 1/1/6 2/13/6
|
||||||
50
browser_tests/assets/widgets/image_crop_widget.json
Normal file
50
browser_tests/assets/widgets/image_crop_widget.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 1,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "ImageCropV2",
|
||||||
|
"pos": [50, 50],
|
||||||
|
"size": [400, 500],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "ImageCropV2"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 512,
|
||||||
|
"height": 512
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"offset": [0, 0],
|
||||||
|
"scale": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
100
browser_tests/assets/widgets/image_crop_with_source.json
Normal file
100
browser_tests/assets/widgets/image_crop_with_source.json
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 3,
|
||||||
|
"last_link_id": 2,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "LoadImage",
|
||||||
|
"pos": [50, 50],
|
||||||
|
"size": [315, 314],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MASK",
|
||||||
|
"type": "MASK",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "LoadImage"
|
||||||
|
},
|
||||||
|
"widgets_values": ["example.png", "image"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "ImageCropV2",
|
||||||
|
"pos": [450, 50],
|
||||||
|
"size": [400, 500],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [2]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "ImageCropV2"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
{
|
||||||
|
"x": 10,
|
||||||
|
"y": 10,
|
||||||
|
"width": 100,
|
||||||
|
"height": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "PreviewImage",
|
||||||
|
"pos": [900, 50],
|
||||||
|
"size": [315, 270],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "PreviewImage"
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[1, 1, 0, 2, 0, "IMAGE"],
|
||||||
|
[2, 2, 0, 3, 0, "IMAGE"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"offset": [0, 0],
|
||||||
|
"scale": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
@@ -26,11 +26,10 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
|||||||
static defaultSteps = 5
|
static defaultSteps = 5
|
||||||
static defaultOptions: DragOptions = { steps: ComfyMouse.defaultSteps }
|
static defaultOptions: DragOptions = { steps: ComfyMouse.defaultSteps }
|
||||||
|
|
||||||
constructor(readonly comfyPage: ComfyPage) {}
|
readonly mouse: Mouse
|
||||||
|
|
||||||
/** The normal Playwright {@link Mouse} property from {@link ComfyPage.page}. */
|
constructor(readonly comfyPage: ComfyPage) {
|
||||||
get mouse() {
|
this.mouse = comfyPage.page.mouse
|
||||||
return this.comfyPage.page.mouse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async nextFrame() {
|
async nextFrame() {
|
||||||
|
|||||||
@@ -73,15 +73,13 @@ class ComfyMenu {
|
|||||||
public readonly sideToolbar: Locator
|
public readonly sideToolbar: Locator
|
||||||
public readonly propertiesPanel: ComfyPropertiesPanel
|
public readonly propertiesPanel: ComfyPropertiesPanel
|
||||||
public readonly modeToggleButton: Locator
|
public readonly modeToggleButton: Locator
|
||||||
|
public readonly buttons: Locator
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
constructor(public readonly page: Page) {
|
||||||
this.sideToolbar = page.getByTestId(TestIds.sidebar.toolbar)
|
this.sideToolbar = page.getByTestId(TestIds.sidebar.toolbar)
|
||||||
this.modeToggleButton = page.getByTestId(TestIds.sidebar.modeToggle)
|
this.modeToggleButton = page.getByTestId(TestIds.sidebar.modeToggle)
|
||||||
this.propertiesPanel = new ComfyPropertiesPanel(page)
|
this.propertiesPanel = new ComfyPropertiesPanel(page)
|
||||||
}
|
this.buttons = this.sideToolbar.locator('.side-bar-button')
|
||||||
|
|
||||||
get buttons() {
|
|
||||||
return this.sideToolbar.locator('.side-bar-button')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get modelLibraryTab() {
|
get modelLibraryTab() {
|
||||||
@@ -183,6 +181,7 @@ export class ComfyPage {
|
|||||||
public readonly assetApi: AssetHelper
|
public readonly assetApi: AssetHelper
|
||||||
public readonly modelLibrary: ModelLibraryHelper
|
public readonly modelLibrary: ModelLibraryHelper
|
||||||
public readonly cloudAuth: CloudAuthHelper
|
public readonly cloudAuth: CloudAuthHelper
|
||||||
|
public readonly visibleToasts: Locator
|
||||||
|
|
||||||
/** Worker index to test user ID */
|
/** Worker index to test user ID */
|
||||||
public readonly userIds: string[] = []
|
public readonly userIds: string[] = []
|
||||||
@@ -225,6 +224,7 @@ export class ComfyPage {
|
|||||||
this.workflow = new WorkflowHelper(this)
|
this.workflow = new WorkflowHelper(this)
|
||||||
this.contextMenu = new ContextMenu(page)
|
this.contextMenu = new ContextMenu(page)
|
||||||
this.toast = new ToastHelper(page)
|
this.toast = new ToastHelper(page)
|
||||||
|
this.visibleToasts = this.toast.visibleToasts
|
||||||
this.dragDrop = new DragDropHelper(page)
|
this.dragDrop = new DragDropHelper(page)
|
||||||
this.featureFlags = new FeatureFlagHelper(page)
|
this.featureFlags = new FeatureFlagHelper(page)
|
||||||
this.command = new CommandHelper(page)
|
this.command = new CommandHelper(page)
|
||||||
@@ -237,10 +237,6 @@ export class ComfyPage {
|
|||||||
this.cloudAuth = new CloudAuthHelper(page)
|
this.cloudAuth = new CloudAuthHelper(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
get visibleToasts() {
|
|
||||||
return this.toast.visibleToasts
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupUser(username: string) {
|
async setupUser(username: string) {
|
||||||
const res = await this.request.get(`${this.url}/api/users`)
|
const res = await this.request.get(`${this.url}/api/users`)
|
||||||
if (res.status() !== 200)
|
if (res.status() !== 200)
|
||||||
@@ -325,7 +321,7 @@ export class ComfyPage {
|
|||||||
// window.app.extensionManager => GraphView ready
|
// window.app.extensionManager => GraphView ready
|
||||||
window.app && window.app.extensionManager
|
window.app && window.app.extensionManager
|
||||||
)
|
)
|
||||||
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
|
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
|
||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +371,7 @@ export class ComfyPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async closeMenu() {
|
async closeMenu() {
|
||||||
await this.page.click('button.comfy-close-menu-btn')
|
await this.page.locator('button.comfy-close-menu-btn').click()
|
||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,9 +388,8 @@ export class ComfyPage {
|
|||||||
await modal.waitFor({ state: 'hidden' })
|
await modal.waitFor({ state: 'hidden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get number of DOM widgets on the canvas. */
|
get domWidgets(): Locator {
|
||||||
async getDOMWidgetCount() {
|
return this.page.locator('.dom-widget')
|
||||||
return await this.page.locator('.dom-widget').count()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFocusMode(focusMode: boolean) {
|
async setFocusMode(focusMode: boolean) {
|
||||||
|
|||||||
@@ -1,30 +1,22 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Locator, Page } from '@playwright/test'
|
||||||
import { test as base } from '@playwright/test'
|
import { test as base } from '@playwright/test'
|
||||||
|
|
||||||
export class UserSelectPage {
|
export class UserSelectPage {
|
||||||
|
public readonly selectionUrl: string
|
||||||
|
public readonly container: Locator
|
||||||
|
public readonly newUserInput: Locator
|
||||||
|
public readonly existingUserSelect: Locator
|
||||||
|
public readonly nextButton: Locator
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly url: string,
|
public readonly url: string,
|
||||||
public readonly page: Page
|
public readonly page: Page
|
||||||
) {}
|
) {
|
||||||
|
this.selectionUrl = url + '/user-select'
|
||||||
get selectionUrl() {
|
this.container = page.locator('#comfy-user-selection')
|
||||||
return this.url + '/user-select'
|
this.newUserInput = this.container.locator('#new-user-input')
|
||||||
}
|
this.existingUserSelect = this.container.locator('#existing-user-select')
|
||||||
|
this.nextButton = this.container.getByText('Next')
|
||||||
get container() {
|
|
||||||
return this.page.locator('#comfy-user-selection')
|
|
||||||
}
|
|
||||||
|
|
||||||
get newUserInput() {
|
|
||||||
return this.container.locator('#new-user-input')
|
|
||||||
}
|
|
||||||
|
|
||||||
get existingUserSelect() {
|
|
||||||
return this.container.locator('#existing-user-select')
|
|
||||||
}
|
|
||||||
|
|
||||||
get nextButton() {
|
|
||||||
return this.container.getByText('Next')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
|||||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||||
|
|
||||||
export class VueNodeHelpers {
|
export class VueNodeHelpers {
|
||||||
constructor(private page: Page) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get locator for all Vue node components in the DOM
|
* Get locator for all Vue node components in the DOM
|
||||||
*/
|
*/
|
||||||
get nodes(): Locator {
|
public readonly nodes: Locator
|
||||||
return this.page.locator('[data-node-id]')
|
/**
|
||||||
|
* Get locator for selected Vue node components (using visual selection indicators)
|
||||||
|
*/
|
||||||
|
public readonly selectedNodes: Locator
|
||||||
|
|
||||||
|
constructor(private page: Page) {
|
||||||
|
this.nodes = page.locator('[data-node-id]')
|
||||||
|
this.selectedNodes = page.locator(
|
||||||
|
'[data-node-id].outline-node-component-outline'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,13 +30,6 @@ export class VueNodeHelpers {
|
|||||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get locator for selected Vue node components (using visual selection indicators)
|
|
||||||
*/
|
|
||||||
get selectedNodes(): Locator {
|
|
||||||
return this.page.locator('[data-node-id].outline-node-component-outline')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||||
* Matches against the actual title element, not the full node body.
|
* Matches against the actual title element, not the full node body.
|
||||||
@@ -37,7 +37,7 @@ export class VueNodeHelpers {
|
|||||||
*/
|
*/
|
||||||
getNodeByTitle(title: string): Locator {
|
getNodeByTitle(title: string): Locator {
|
||||||
return this.page.locator('[data-node-id]').filter({
|
return this.page.locator('[data-node-id]').filter({
|
||||||
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
|
has: this.page.getByTestId('node-title').filter({ hasText: title })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +48,6 @@ export class VueNodeHelpers {
|
|||||||
return await this.nodes.count()
|
return await this.nodes.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of selected Vue nodes
|
|
||||||
*/
|
|
||||||
async getSelectedNodeCount(): Promise<number> {
|
|
||||||
return await this.selectedNodes.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all Vue node IDs currently in the DOM
|
* Get all Vue node IDs currently in the DOM
|
||||||
*/
|
*/
|
||||||
@@ -153,7 +146,7 @@ export class VueNodeHelpers {
|
|||||||
expectedCount
|
expectedCount
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await this.page.waitForSelector('[data-node-id]')
|
await this.page.locator('[data-node-id]').first().waitFor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class BaseDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.closeButton.click({ force: true })
|
await this.closeButton.click()
|
||||||
await this.waitForHidden()
|
await this.waitForHidden()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import type { Locator, Page } from '@playwright/test'
|
|||||||
|
|
||||||
export class ComfyNodeSearchFilterSelectionPanel {
|
export class ComfyNodeSearchFilterSelectionPanel {
|
||||||
readonly root: Locator
|
readonly root: Locator
|
||||||
|
readonly header: Locator
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
constructor(public readonly page: Page) {
|
||||||
this.root = page.getByRole('dialog')
|
this.root = page.getByRole('dialog')
|
||||||
}
|
this.header = this.root
|
||||||
|
|
||||||
get header() {
|
|
||||||
return this.root
|
|
||||||
.locator('div')
|
.locator('div')
|
||||||
.filter({ hasText: 'Add node filter condition' })
|
.filter({ hasText: 'Add node filter condition' })
|
||||||
}
|
}
|
||||||
@@ -41,6 +39,8 @@ export class ComfyNodeSearchFilterSelectionPanel {
|
|||||||
export class ComfyNodeSearchBox {
|
export class ComfyNodeSearchBox {
|
||||||
public readonly input: Locator
|
public readonly input: Locator
|
||||||
public readonly dropdown: Locator
|
public readonly dropdown: Locator
|
||||||
|
public readonly filterButton: Locator
|
||||||
|
public readonly filterChips: Locator
|
||||||
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
|
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
constructor(public readonly page: Page) {
|
||||||
@@ -50,13 +50,15 @@ export class ComfyNodeSearchBox {
|
|||||||
this.dropdown = page.locator(
|
this.dropdown = page.locator(
|
||||||
'.comfy-vue-node-search-container .p-autocomplete-list'
|
'.comfy-vue-node-search-container .p-autocomplete-list'
|
||||||
)
|
)
|
||||||
|
this.filterButton = page.locator(
|
||||||
|
'.comfy-vue-node-search-container .filter-button'
|
||||||
|
)
|
||||||
|
this.filterChips = page.locator(
|
||||||
|
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||||
|
)
|
||||||
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
|
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
get filterButton() {
|
|
||||||
return this.page.locator('.comfy-vue-node-search-container .filter-button')
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillAndSelectFirstNode(
|
async fillAndSelectFirstNode(
|
||||||
nodeName: string,
|
nodeName: string,
|
||||||
options?: { suggestionIndex?: number; exact?: boolean }
|
options?: { suggestionIndex?: number; exact?: boolean }
|
||||||
@@ -78,12 +80,6 @@ export class ComfyNodeSearchBox {
|
|||||||
await this.filterSelectionPanel.addFilter(filterValue, filterType)
|
await this.filterSelectionPanel.addFilter(filterValue, filterType)
|
||||||
}
|
}
|
||||||
|
|
||||||
get filterChips() {
|
|
||||||
return this.page.locator(
|
|
||||||
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFilter(index: number) {
|
async removeFilter(index: number) {
|
||||||
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
|
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|||||||
export class ComfyNodeSearchBoxV2 {
|
export class ComfyNodeSearchBoxV2 {
|
||||||
readonly dialog: Locator
|
readonly dialog: Locator
|
||||||
readonly input: Locator
|
readonly input: Locator
|
||||||
|
readonly filterSearch: Locator
|
||||||
readonly results: Locator
|
readonly results: Locator
|
||||||
readonly filterOptions: Locator
|
readonly filterOptions: Locator
|
||||||
|
|
||||||
constructor(readonly page: Page) {
|
constructor(readonly page: Page) {
|
||||||
this.dialog = page.getByRole('search')
|
this.dialog = page.getByRole('search')
|
||||||
this.input = this.dialog.locator('input[type="text"]')
|
this.input = this.dialog.getByRole('combobox')
|
||||||
|
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
|
||||||
this.results = this.dialog.getByTestId('result-item')
|
this.results = this.dialog.getByTestId('result-item')
|
||||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,14 @@ import { expect } from '@playwright/test'
|
|||||||
import type { Locator, Page } from '@playwright/test'
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
export class ContextMenu {
|
export class ContextMenu {
|
||||||
constructor(public readonly page: Page) {}
|
public readonly primeVueMenu: Locator
|
||||||
|
public readonly litegraphMenu: Locator
|
||||||
|
public readonly menuItems: Locator
|
||||||
|
|
||||||
get primeVueMenu() {
|
constructor(public readonly page: Page) {
|
||||||
return this.page.locator('.p-contextmenu, .p-menu')
|
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||||
}
|
this.litegraphMenu = page.locator('.litemenu')
|
||||||
|
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||||
get litegraphMenu() {
|
|
||||||
return this.page.locator('.litemenu')
|
|
||||||
}
|
|
||||||
|
|
||||||
get menuItems() {
|
|
||||||
return this.page.locator('.p-menuitem, .litemenu-entry')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickMenuItem(name: string): Promise<void> {
|
async clickMenuItem(name: string): Promise<void> {
|
||||||
@@ -65,21 +61,9 @@ export class ContextMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async waitForHidden(): Promise<void> {
|
async waitForHidden(): Promise<void> {
|
||||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
|
||||||
const count = await locator.count()
|
|
||||||
if (count > 0) {
|
|
||||||
await locator.waitFor({ state: 'hidden' }).catch((error: Error) => {
|
|
||||||
console.warn(
|
|
||||||
`[waitForHidden] ${menuName} waitFor failed:`,
|
|
||||||
error.message
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
waitIfExists(this.primeVueMenu, 'primeVueMenu'),
|
this.primeVueMenu.waitFor({ state: 'hidden' }),
|
||||||
waitIfExists(this.litegraphMenu, 'litegraphMenu')
|
this.litegraphMenu.waitFor({ state: 'hidden' })
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
browser_tests/fixtures/components/OutputHistory.ts
Normal file
97
browser_tests/fixtures/components/OutputHistory.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { TestIds } from '../selectors'
|
||||||
|
|
||||||
|
const ids = TestIds.outputHistory
|
||||||
|
|
||||||
|
export class OutputHistoryComponent {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
get outputs(): Locator {
|
||||||
|
return this.page.getByTestId(ids.outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
get welcome(): Locator {
|
||||||
|
return this.page.getByTestId(ids.welcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
get outputInfo(): Locator {
|
||||||
|
return this.page.getByTestId(ids.outputInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeQueue(): Locator {
|
||||||
|
return this.page.getByTestId(ids.activeQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
get queueBadge(): Locator {
|
||||||
|
return this.page.getByTestId(ids.queueBadge)
|
||||||
|
}
|
||||||
|
|
||||||
|
get inProgressItems(): Locator {
|
||||||
|
return this.page.getByTestId(ids.inProgressItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
get historyItems(): Locator {
|
||||||
|
return this.page.getByTestId(ids.historyItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
get skeletons(): Locator {
|
||||||
|
return this.page.getByTestId(ids.skeleton)
|
||||||
|
}
|
||||||
|
|
||||||
|
get latentPreviews(): Locator {
|
||||||
|
return this.page.getByTestId(ids.latentPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
get imageOutputs(): Locator {
|
||||||
|
return this.page.getByTestId(ids.imageOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
get videoOutputs(): Locator {
|
||||||
|
return this.page.getByTestId(ids.videoOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently selected (checked) in-progress item. */
|
||||||
|
get selectedInProgressItem(): Locator {
|
||||||
|
return this.page.locator(
|
||||||
|
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently selected (checked) history item. */
|
||||||
|
get selectedHistoryItem(): Locator {
|
||||||
|
return this.page.locator(
|
||||||
|
`[data-testid="${ids.historyItem}"][data-state="checked"]`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header-level progress bar. */
|
||||||
|
get headerProgressBar(): Locator {
|
||||||
|
return this.page.getByTestId(ids.headerProgressBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The in-progress item's progress bar (inside the thumbnail). */
|
||||||
|
get itemProgressBar(): Locator {
|
||||||
|
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall progress in the header bar. */
|
||||||
|
get headerOverallProgress(): Locator {
|
||||||
|
return this.headerProgressBar.getByTestId(ids.progressOverall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node progress in the header bar. */
|
||||||
|
get headerNodeProgress(): Locator {
|
||||||
|
return this.headerProgressBar.getByTestId(ids.progressNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall progress in the in-progress item bar. */
|
||||||
|
get itemOverallProgress(): Locator {
|
||||||
|
return this.itemProgressBar.getByTestId(ids.progressOverall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node progress in the in-progress item bar. */
|
||||||
|
get itemNodeProgress(): Locator {
|
||||||
|
return this.itemProgressBar.getByTestId(ids.progressNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
import { TestIds } from '@e2e/fixtures/selectors'
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||||
|
|
||||||
export class SettingDialog extends BaseDialog {
|
export class SettingDialog extends BaseDialog {
|
||||||
|
public readonly searchBox: Locator
|
||||||
|
public readonly categories: Locator
|
||||||
|
public readonly contentArea: Locator
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
page: Page,
|
page: Page,
|
||||||
public readonly comfyPage: ComfyPage
|
public readonly comfyPage: ComfyPage
|
||||||
) {
|
) {
|
||||||
super(page, TestIds.dialogs.settings)
|
super(page, TestIds.dialogs.settings)
|
||||||
|
this.searchBox = this.root.getByPlaceholder(/Search/)
|
||||||
|
this.categories = this.root.locator('nav').getByRole('button')
|
||||||
|
this.contentArea = this.root.getByRole('main')
|
||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
async open() {
|
||||||
@@ -36,27 +43,15 @@ export class SettingDialog extends BaseDialog {
|
|||||||
await settingInputDiv.locator('input').click()
|
await settingInputDiv.locator('input').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
get searchBox() {
|
|
||||||
return this.root.getByPlaceholder(/Search/)
|
|
||||||
}
|
|
||||||
|
|
||||||
get categories() {
|
|
||||||
return this.root.locator('nav').getByRole('button')
|
|
||||||
}
|
|
||||||
|
|
||||||
category(name: string) {
|
category(name: string) {
|
||||||
return this.root.locator('nav').getByRole('button', { name })
|
return this.root.locator('nav').getByRole('button', { name })
|
||||||
}
|
}
|
||||||
|
|
||||||
get contentArea() {
|
|
||||||
return this.root.getByRole('main')
|
|
||||||
}
|
|
||||||
|
|
||||||
async goToAboutPanel() {
|
async goToAboutPanel() {
|
||||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||||
name: 'About'
|
name: 'About'
|
||||||
})
|
})
|
||||||
await aboutButton.click()
|
await aboutButton.click()
|
||||||
await this.page.waitForSelector('.about-container')
|
await this.page.locator('.about-container').waitFor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
|||||||
import { TestIds } from '@e2e/fixtures/selectors'
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
|
|
||||||
class SidebarTab {
|
class SidebarTab {
|
||||||
|
public readonly tabButton: Locator
|
||||||
|
public readonly selectedTabButton: Locator
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly page: Page,
|
public readonly page: Page,
|
||||||
public readonly tabId: string
|
public readonly tabId: string
|
||||||
) {}
|
) {
|
||||||
|
this.tabButton = page.locator(`.${tabId}-tab-button`)
|
||||||
get tabButton() {
|
this.selectedTabButton = page.locator(
|
||||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
`.${tabId}-tab-button.side-bar-button-selected`
|
||||||
}
|
|
||||||
|
|
||||||
get selectedTabButton() {
|
|
||||||
return this.page.locator(
|
|
||||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,28 +33,19 @@ class SidebarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class NodeLibrarySidebarTab extends SidebarTab {
|
export class NodeLibrarySidebarTab extends SidebarTab {
|
||||||
|
public readonly nodeLibrarySearchBoxInput: Locator
|
||||||
|
public readonly nodeLibraryTree: Locator
|
||||||
|
public readonly nodePreview: Locator
|
||||||
|
public readonly tabContainer: Locator
|
||||||
|
public readonly newFolderButton: Locator
|
||||||
|
|
||||||
constructor(public override readonly page: Page) {
|
constructor(public override readonly page: Page) {
|
||||||
super(page, 'node-library')
|
super(page, 'node-library')
|
||||||
}
|
this.nodeLibrarySearchBoxInput = page.getByPlaceholder('Search Nodes...')
|
||||||
|
this.nodeLibraryTree = page.getByTestId(TestIds.sidebar.nodeLibrary)
|
||||||
get nodeLibrarySearchBoxInput() {
|
this.nodePreview = page.locator('.node-lib-node-preview')
|
||||||
return this.page.getByPlaceholder('Search Nodes...')
|
this.tabContainer = page.locator('.sidebar-content-container')
|
||||||
}
|
this.newFolderButton = this.tabContainer.locator('.new-folder-button')
|
||||||
|
|
||||||
get nodeLibraryTree() {
|
|
||||||
return this.page.getByTestId(TestIds.sidebar.nodeLibrary)
|
|
||||||
}
|
|
||||||
|
|
||||||
get nodePreview() {
|
|
||||||
return this.page.locator('.node-lib-node-preview')
|
|
||||||
}
|
|
||||||
|
|
||||||
get tabContainer() {
|
|
||||||
return this.page.locator('.sidebar-content-container')
|
|
||||||
}
|
|
||||||
|
|
||||||
get newFolderButton() {
|
|
||||||
return this.tabContainer.locator('.new-folder-button')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async open() {
|
override async open() {
|
||||||
@@ -101,34 +90,25 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||||
|
public readonly searchInput: Locator
|
||||||
|
public readonly sidebarContent: Locator
|
||||||
|
public readonly allTab: Locator
|
||||||
|
public readonly blueprintsTab: Locator
|
||||||
|
public readonly sortButton: Locator
|
||||||
|
|
||||||
constructor(public override readonly page: Page) {
|
constructor(public override readonly page: Page) {
|
||||||
super(page, 'node-library')
|
super(page, 'node-library')
|
||||||
}
|
this.searchInput = page.getByPlaceholder('Search...')
|
||||||
|
this.sidebarContent = page.locator('.sidebar-content-container')
|
||||||
get searchInput() {
|
this.allTab = this.getTab('All')
|
||||||
return this.page.getByPlaceholder('Search...')
|
this.blueprintsTab = this.getTab('Blueprints')
|
||||||
}
|
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||||
|
|
||||||
get sidebarContent() {
|
|
||||||
return this.page.locator('.sidebar-content-container')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getTab(name: string) {
|
getTab(name: string) {
|
||||||
return this.sidebarContent.getByRole('tab', { name, exact: true })
|
return this.sidebarContent.getByRole('tab', { name, exact: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
get allTab() {
|
|
||||||
return this.getTab('All')
|
|
||||||
}
|
|
||||||
|
|
||||||
get blueprintsTab() {
|
|
||||||
return this.getTab('Blueprints')
|
|
||||||
}
|
|
||||||
|
|
||||||
get sortButton() {
|
|
||||||
return this.sidebarContent.getByRole('button', { name: 'Sort' })
|
|
||||||
}
|
|
||||||
|
|
||||||
getFolder(folderName: string) {
|
getFolder(folderName: string) {
|
||||||
return this.sidebarContent
|
return this.sidebarContent
|
||||||
.getByRole('treeitem', { name: folderName })
|
.getByRole('treeitem', { name: folderName })
|
||||||
@@ -154,12 +134,15 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class WorkflowsSidebarTab extends SidebarTab {
|
export class WorkflowsSidebarTab extends SidebarTab {
|
||||||
|
public readonly root: Locator
|
||||||
|
public readonly activeWorkflowLabel: Locator
|
||||||
|
|
||||||
constructor(public override readonly page: Page) {
|
constructor(public override readonly page: Page) {
|
||||||
super(page, 'workflows')
|
super(page, 'workflows')
|
||||||
}
|
this.root = page.getByTestId(TestIds.sidebar.workflows)
|
||||||
|
this.activeWorkflowLabel = this.root.locator(
|
||||||
get root() {
|
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||||
return this.page.getByTestId(TestIds.sidebar.workflows)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOpenedWorkflowNames() {
|
async getOpenedWorkflowNames() {
|
||||||
@@ -169,9 +152,7 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getActiveWorkflowName() {
|
async getActiveWorkflowName() {
|
||||||
return await this.root
|
return await this.activeWorkflowLabel.innerText()
|
||||||
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
|
|
||||||
.innerText()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopLevelSavedWorkflowNames() {
|
async getTopLevelSavedWorkflowNames() {
|
||||||
@@ -224,36 +205,27 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ModelLibrarySidebarTab extends SidebarTab {
|
export class ModelLibrarySidebarTab extends SidebarTab {
|
||||||
|
public readonly searchInput: Locator
|
||||||
|
public readonly modelTree: Locator
|
||||||
|
public readonly refreshButton: Locator
|
||||||
|
public readonly loadAllFoldersButton: Locator
|
||||||
|
public readonly folderNodes: Locator
|
||||||
|
public readonly leafNodes: Locator
|
||||||
|
public readonly modelPreview: Locator
|
||||||
|
|
||||||
constructor(public override readonly page: Page) {
|
constructor(public override readonly page: Page) {
|
||||||
super(page, 'model-library')
|
super(page, 'model-library')
|
||||||
}
|
this.searchInput = page.getByPlaceholder('Search Models...')
|
||||||
|
this.modelTree = page.locator('.model-lib-tree-explorer')
|
||||||
get searchInput() {
|
this.refreshButton = page.getByRole('button', { name: 'Refresh' })
|
||||||
return this.page.getByPlaceholder('Search Models...')
|
this.loadAllFoldersButton = page.getByRole('button', {
|
||||||
}
|
name: 'Load All Folders'
|
||||||
|
})
|
||||||
get modelTree() {
|
this.folderNodes = this.modelTree.locator(
|
||||||
return this.page.locator('.model-lib-tree-explorer')
|
'.p-tree-node:not(.p-tree-node-leaf)'
|
||||||
}
|
)
|
||||||
|
this.leafNodes = this.modelTree.locator('.p-tree-node-leaf')
|
||||||
get refreshButton() {
|
this.modelPreview = page.locator('.model-lib-model-preview')
|
||||||
return this.page.getByRole('button', { name: 'Refresh' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get loadAllFoldersButton() {
|
|
||||||
return this.page.getByRole('button', { name: 'Load All Folders' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get folderNodes() {
|
|
||||||
return this.modelTree.locator('.p-tree-node:not(.p-tree-node-leaf)')
|
|
||||||
}
|
|
||||||
|
|
||||||
get leafNodes() {
|
|
||||||
return this.modelTree.locator('.p-tree-node-leaf')
|
|
||||||
}
|
|
||||||
|
|
||||||
get modelPreview() {
|
|
||||||
return this.page.locator('.model-lib-model-preview')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async open() {
|
override async open() {
|
||||||
@@ -277,137 +249,97 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AssetsSidebarTab extends SidebarTab {
|
export class AssetsSidebarTab extends SidebarTab {
|
||||||
constructor(public override readonly page: Page) {
|
|
||||||
super(page, 'assets')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tab navigation ---
|
// --- Tab navigation ---
|
||||||
|
public readonly generatedTab: Locator
|
||||||
get generatedTab() {
|
public readonly importedTab: Locator
|
||||||
return this.page.getByRole('tab', { name: 'Generated' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get importedTab() {
|
|
||||||
return this.page.getByRole('tab', { name: 'Imported' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Empty state ---
|
// --- Empty state ---
|
||||||
|
public readonly emptyStateMessage: Locator
|
||||||
|
|
||||||
get emptyStateMessage() {
|
// --- Search & filter ---
|
||||||
return this.page.getByText(
|
public readonly searchInput: Locator
|
||||||
|
public readonly settingsButton: Locator
|
||||||
|
|
||||||
|
// --- View mode ---
|
||||||
|
public readonly listViewOption: Locator
|
||||||
|
public readonly gridViewOption: Locator
|
||||||
|
|
||||||
|
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||||
|
public readonly sortNewestFirst: Locator
|
||||||
|
public readonly sortOldestFirst: Locator
|
||||||
|
|
||||||
|
// --- Asset cards ---
|
||||||
|
public readonly assetCards: Locator
|
||||||
|
public readonly selectedCards: Locator
|
||||||
|
|
||||||
|
// --- List view items ---
|
||||||
|
public readonly listViewItems: Locator
|
||||||
|
|
||||||
|
// --- Selection footer ---
|
||||||
|
public readonly selectionFooter: Locator
|
||||||
|
public readonly selectionCountButton: Locator
|
||||||
|
public readonly deselectAllButton: Locator
|
||||||
|
public readonly deleteSelectedButton: Locator
|
||||||
|
public readonly downloadSelectedButton: Locator
|
||||||
|
|
||||||
|
// --- Folder view ---
|
||||||
|
public readonly backToAssetsButton: Locator
|
||||||
|
|
||||||
|
// --- Loading ---
|
||||||
|
public readonly skeletonLoaders: Locator
|
||||||
|
|
||||||
|
constructor(public override readonly page: Page) {
|
||||||
|
super(page, 'assets')
|
||||||
|
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
|
||||||
|
this.importedTab = page.getByRole('tab', { name: 'Imported' })
|
||||||
|
this.emptyStateMessage = page.getByText(
|
||||||
'Upload files or generate content to see them here'
|
'Upload files or generate content to see them here'
|
||||||
)
|
)
|
||||||
|
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||||
|
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||||
|
this.listViewOption = page.getByText('List view')
|
||||||
|
this.gridViewOption = page.getByText('Grid view')
|
||||||
|
this.sortNewestFirst = page.getByText('Newest first')
|
||||||
|
this.sortOldestFirst = page.getByText('Oldest first')
|
||||||
|
this.assetCards = page
|
||||||
|
.getByRole('button')
|
||||||
|
.and(page.locator('[data-selected]'))
|
||||||
|
this.selectedCards = page.locator('[data-selected="true"]')
|
||||||
|
this.listViewItems = page.locator(
|
||||||
|
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||||
|
)
|
||||||
|
this.selectionFooter = page
|
||||||
|
.locator('.sidebar-content-container')
|
||||||
|
.locator('..')
|
||||||
|
.locator('[class*="h-18"]')
|
||||||
|
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||||
|
this.deselectAllButton = page.getByText('Deselect all')
|
||||||
|
this.deleteSelectedButton = page
|
||||||
|
.getByTestId('assets-delete-selected')
|
||||||
|
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||||
|
.first()
|
||||||
|
this.downloadSelectedButton = page
|
||||||
|
.getByTestId('assets-download-selected')
|
||||||
|
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||||
|
.first()
|
||||||
|
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||||
|
this.skeletonLoaders = page.locator(
|
||||||
|
'.sidebar-content-container .animate-pulse'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
emptyStateTitle(title: string) {
|
emptyStateTitle(title: string) {
|
||||||
return this.page.getByText(title)
|
return this.page.getByText(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Search & filter ---
|
|
||||||
|
|
||||||
get searchInput() {
|
|
||||||
return this.page.getByPlaceholder('Search Assets...')
|
|
||||||
}
|
|
||||||
|
|
||||||
get settingsButton() {
|
|
||||||
return this.page.getByRole('button', { name: 'View settings' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- View mode ---
|
|
||||||
|
|
||||||
get listViewOption() {
|
|
||||||
return this.page.getByText('List view')
|
|
||||||
}
|
|
||||||
|
|
||||||
get gridViewOption() {
|
|
||||||
return this.page.getByText('Grid view')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
|
||||||
|
|
||||||
get sortNewestFirst() {
|
|
||||||
return this.page.getByText('Newest first')
|
|
||||||
}
|
|
||||||
|
|
||||||
get sortOldestFirst() {
|
|
||||||
return this.page.getByText('Oldest first')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Asset cards ---
|
|
||||||
|
|
||||||
get assetCards() {
|
|
||||||
return this.page.locator('[role="button"][data-selected]')
|
|
||||||
}
|
|
||||||
|
|
||||||
getAssetCardByName(name: string) {
|
getAssetCardByName(name: string) {
|
||||||
return this.page.locator('[role="button"][data-selected]', {
|
return this.assetCards.filter({ hasText: name })
|
||||||
hasText: name
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedCards() {
|
|
||||||
return this.page.locator('[data-selected="true"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- List view items ---
|
|
||||||
|
|
||||||
get listViewItems() {
|
|
||||||
return this.page.locator(
|
|
||||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Selection footer ---
|
|
||||||
|
|
||||||
get selectionFooter() {
|
|
||||||
return this.page
|
|
||||||
.locator('.sidebar-content-container')
|
|
||||||
.locator('..')
|
|
||||||
.locator('[class*="h-18"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
get selectionCountButton() {
|
|
||||||
return this.page.getByText(/Assets Selected: \d+/)
|
|
||||||
}
|
|
||||||
|
|
||||||
get deselectAllButton() {
|
|
||||||
return this.page.getByText('Deselect all')
|
|
||||||
}
|
|
||||||
|
|
||||||
get deleteSelectedButton() {
|
|
||||||
return this.page
|
|
||||||
.getByTestId('assets-delete-selected')
|
|
||||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
|
||||||
.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
get downloadSelectedButton() {
|
|
||||||
return this.page
|
|
||||||
.getByTestId('assets-download-selected')
|
|
||||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
|
||||||
.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Context menu ---
|
|
||||||
|
|
||||||
contextMenuItem(label: string) {
|
contextMenuItem(label: string) {
|
||||||
return this.page.locator('.p-contextmenu').getByText(label)
|
return this.page.locator('.p-contextmenu').getByText(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Folder view ---
|
|
||||||
|
|
||||||
get backToAssetsButton() {
|
|
||||||
return this.page.getByText('Back to all assets')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Loading ---
|
|
||||||
|
|
||||||
get skeletonLoaders() {
|
|
||||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
override async open() {
|
override async open() {
|
||||||
// Remove any toast notifications that may overlay the sidebar button
|
// Remove any toast notifications that may overlay the sidebar button
|
||||||
await this.dismissToasts()
|
await this.dismissToasts()
|
||||||
@@ -419,28 +351,24 @@ export class AssetsSidebarTab extends SidebarTab {
|
|||||||
async dismissToasts() {
|
async dismissToasts() {
|
||||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||||
for (const btn of await closeButtons.all()) {
|
for (const btn of await closeButtons.all()) {
|
||||||
await btn.click({ force: true }).catch(() => {})
|
await btn.click().catch(() => {})
|
||||||
}
|
}
|
||||||
// Wait for all toast elements to fully animate out and detach from DOM
|
// Wait for all toast elements to fully animate out and detach from DOM
|
||||||
await expect(this.page.locator('.p-toast-message'))
|
await expect(this.page.locator('.p-toast-message'))
|
||||||
.toHaveCount(0, { timeout: 5000 })
|
.toHaveCount(0)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchToImported() {
|
async switchToImported() {
|
||||||
await this.dismissToasts()
|
await this.dismissToasts()
|
||||||
await this.importedTab.click()
|
await this.importedTab.click()
|
||||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||||
timeout: 3000
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchToGenerated() {
|
async switchToGenerated() {
|
||||||
await this.dismissToasts()
|
await this.dismissToasts()
|
||||||
await this.generatedTab.click()
|
await this.generatedTab.click()
|
||||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||||
timeout: 3000
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async openSettingsMenu() {
|
async openSettingsMenu() {
|
||||||
@@ -463,7 +391,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
|||||||
|
|
||||||
async waitForAssets(count?: number) {
|
async waitForAssets(count?: number) {
|
||||||
if (count !== undefined) {
|
if (count !== undefined) {
|
||||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
await expect(this.assetCards).toHaveCount(count)
|
||||||
} else {
|
} else {
|
||||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ export class SignInDialog extends BaseDialog {
|
|||||||
readonly apiKeyButton: Locator
|
readonly apiKeyButton: Locator
|
||||||
readonly termsLink: Locator
|
readonly termsLink: Locator
|
||||||
readonly privacyLink: Locator
|
readonly privacyLink: Locator
|
||||||
|
readonly heading: Locator
|
||||||
|
readonly signUpLink: Locator
|
||||||
|
readonly signInLink: Locator
|
||||||
|
readonly signUpEmailInput: Locator
|
||||||
|
readonly signUpPasswordInput: Locator
|
||||||
|
readonly signUpConfirmPasswordInput: Locator
|
||||||
|
readonly signUpButton: Locator
|
||||||
|
readonly apiKeyHeading: Locator
|
||||||
|
readonly apiKeyInput: Locator
|
||||||
|
readonly backButton: Locator
|
||||||
|
readonly dividerText: Locator
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
super(page)
|
super(page)
|
||||||
@@ -22,6 +33,22 @@ export class SignInDialog extends BaseDialog {
|
|||||||
})
|
})
|
||||||
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
||||||
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
||||||
|
this.heading = this.root.getByRole('heading').first()
|
||||||
|
this.signUpLink = this.root.getByText('Sign up', { exact: true })
|
||||||
|
this.signInLink = this.root.getByText('Sign in', { exact: true })
|
||||||
|
this.signUpEmailInput = this.root.locator('#comfy-org-sign-up-email')
|
||||||
|
this.signUpPasswordInput = this.root.locator('#comfy-org-sign-up-password')
|
||||||
|
this.signUpConfirmPasswordInput = this.root.locator(
|
||||||
|
'#comfy-org-sign-up-confirm-password'
|
||||||
|
)
|
||||||
|
this.signUpButton = this.root.getByRole('button', {
|
||||||
|
name: 'Sign up',
|
||||||
|
exact: true
|
||||||
|
})
|
||||||
|
this.apiKeyHeading = this.root.getByRole('heading', { name: 'API Key' })
|
||||||
|
this.apiKeyInput = this.root.locator('#comfy-org-api-key')
|
||||||
|
this.backButton = this.root.getByRole('button', { name: 'Back' })
|
||||||
|
this.dividerText = this.root.getByText('Or continue with')
|
||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
async open() {
|
||||||
@@ -30,48 +57,4 @@ export class SignInDialog extends BaseDialog {
|
|||||||
})
|
})
|
||||||
await this.waitForVisible()
|
await this.waitForVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
get heading() {
|
|
||||||
return this.root.getByRole('heading').first()
|
|
||||||
}
|
|
||||||
|
|
||||||
get signUpLink() {
|
|
||||||
return this.root.getByText('Sign up', { exact: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
get signInLink() {
|
|
||||||
return this.root.getByText('Sign in', { exact: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
get signUpEmailInput() {
|
|
||||||
return this.root.locator('#comfy-org-sign-up-email')
|
|
||||||
}
|
|
||||||
|
|
||||||
get signUpPasswordInput() {
|
|
||||||
return this.root.locator('#comfy-org-sign-up-password')
|
|
||||||
}
|
|
||||||
|
|
||||||
get signUpConfirmPasswordInput() {
|
|
||||||
return this.root.locator('#comfy-org-sign-up-confirm-password')
|
|
||||||
}
|
|
||||||
|
|
||||||
get signUpButton() {
|
|
||||||
return this.root.getByRole('button', { name: 'Sign up', exact: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
get apiKeyHeading() {
|
|
||||||
return this.root.getByRole('heading', { name: 'API Key' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get apiKeyInput() {
|
|
||||||
return this.root.locator('#comfy-org-api-key')
|
|
||||||
}
|
|
||||||
|
|
||||||
get backButton() {
|
|
||||||
return this.root.getByRole('button', { name: 'Back' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get dividerText() {
|
|
||||||
return this.root.getByText('Or continue with')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
|||||||
export class Topbar {
|
export class Topbar {
|
||||||
private readonly menuLocator: Locator
|
private readonly menuLocator: Locator
|
||||||
private readonly menuTrigger: Locator
|
private readonly menuTrigger: Locator
|
||||||
|
readonly newWorkflowButton: Locator
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
constructor(public readonly page: Page) {
|
||||||
this.menuLocator = page.locator('.comfy-command-menu')
|
this.menuLocator = page.locator('.comfy-command-menu')
|
||||||
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
|
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
|
||||||
|
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTabNames(): Promise<string[]> {
|
async getTabNames(): Promise<string[]> {
|
||||||
@@ -50,10 +52,6 @@ export class Topbar {
|
|||||||
return classes ? !classes.includes('invisible') : false
|
return classes ? !classes.includes('invisible') : false
|
||||||
}
|
}
|
||||||
|
|
||||||
get newWorkflowButton(): Locator {
|
|
||||||
return this.page.locator('.new-blank-workflow-button')
|
|
||||||
}
|
|
||||||
|
|
||||||
getWorkflowTab(tabName: string): Locator {
|
getWorkflowTab(tabName: string): Locator {
|
||||||
return this.page
|
return this.page
|
||||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||||
@@ -73,7 +71,7 @@ export class Topbar {
|
|||||||
async closeWorkflowTab(tabName: string) {
|
async closeWorkflowTab(tabName: string) {
|
||||||
const tab = this.getWorkflowTab(tabName)
|
const tab = this.getWorkflowTab(tabName)
|
||||||
await tab.hover()
|
await tab.hover()
|
||||||
await tab.locator('.close-button').click({ force: true })
|
await tab.locator('.close-button').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
getSaveDialog(): Locator {
|
getSaveDialog(): Locator {
|
||||||
@@ -107,7 +105,7 @@ export class Topbar {
|
|||||||
{ timeout: 3000 }
|
{ timeout: 3000 }
|
||||||
)
|
)
|
||||||
// Wait for the dialog to close.
|
// Wait for the dialog to close.
|
||||||
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
await this.getSaveDialog().waitFor({ state: 'hidden' })
|
||||||
|
|
||||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||||
// If so, return early to let the test handle the confirmation
|
// If so, return early to let the test handle the confirmation
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
|
|||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
import { TestIds } from '@e2e/fixtures/selectors'
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
|
|
||||||
|
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
|
||||||
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
||||||
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||||
@@ -14,14 +15,66 @@ export class AppModeHelper {
|
|||||||
readonly footer: BuilderFooterHelper
|
readonly footer: BuilderFooterHelper
|
||||||
readonly saveAs: BuilderSaveAsHelper
|
readonly saveAs: BuilderSaveAsHelper
|
||||||
readonly select: BuilderSelectHelper
|
readonly select: BuilderSelectHelper
|
||||||
|
readonly outputHistory: OutputHistoryComponent
|
||||||
readonly widgets: AppModeWidgetHelper
|
readonly widgets: AppModeWidgetHelper
|
||||||
|
/** The "Connect an output" popover shown when saving without outputs. */
|
||||||
|
public readonly connectOutputPopover: Locator
|
||||||
|
/** The empty-state placeholder shown when no outputs are selected. */
|
||||||
|
public readonly outputPlaceholder: Locator
|
||||||
|
/** The linear-mode widget list container (visible in app mode). */
|
||||||
|
public readonly linearWidgets: Locator
|
||||||
|
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||||
|
public readonly imagePickerPopover: Locator
|
||||||
|
/** The Run button in the app mode footer. */
|
||||||
|
public readonly runButton: Locator
|
||||||
|
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||||
|
public readonly welcome: Locator
|
||||||
|
/** The empty workflow message shown when no nodes exist. */
|
||||||
|
public readonly emptyWorkflowText: Locator
|
||||||
|
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||||
|
public readonly buildAppButton: Locator
|
||||||
|
/** The "Back to workflow" button on the welcome screen. */
|
||||||
|
public readonly backToWorkflowButton: Locator
|
||||||
|
/** The "Load template" button shown when no nodes exist. */
|
||||||
|
public readonly loadTemplateButton: Locator
|
||||||
|
/** The cancel button for an in-progress run in the output history. */
|
||||||
|
public readonly cancelRunButton: Locator
|
||||||
|
|
||||||
constructor(private readonly comfyPage: ComfyPage) {
|
constructor(private readonly comfyPage: ComfyPage) {
|
||||||
this.steps = new BuilderStepsHelper(comfyPage)
|
this.steps = new BuilderStepsHelper(comfyPage)
|
||||||
this.footer = new BuilderFooterHelper(comfyPage)
|
this.footer = new BuilderFooterHelper(comfyPage)
|
||||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||||
this.select = new BuilderSelectHelper(comfyPage)
|
this.select = new BuilderSelectHelper(comfyPage)
|
||||||
|
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||||
|
this.connectOutputPopover = this.page.getByTestId(
|
||||||
|
TestIds.builder.connectOutputPopover
|
||||||
|
)
|
||||||
|
this.outputPlaceholder = this.page.getByTestId(
|
||||||
|
TestIds.builder.outputPlaceholder
|
||||||
|
)
|
||||||
|
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||||
|
this.imagePickerPopover = this.page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||||
|
.first()
|
||||||
|
this.runButton = this.page
|
||||||
|
.getByTestId('linear-run-button')
|
||||||
|
.getByRole('button', { name: /run/i })
|
||||||
|
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||||
|
this.emptyWorkflowText = this.page.getByTestId(
|
||||||
|
TestIds.appMode.emptyWorkflow
|
||||||
|
)
|
||||||
|
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||||
|
this.backToWorkflowButton = this.page.getByTestId(
|
||||||
|
TestIds.appMode.backToWorkflow
|
||||||
|
)
|
||||||
|
this.loadTemplateButton = this.page.getByTestId(
|
||||||
|
TestIds.appMode.loadTemplate
|
||||||
|
)
|
||||||
|
this.cancelRunButton = this.page.getByTestId(
|
||||||
|
TestIds.outputHistory.cancelRun
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private get page(): Page {
|
private get page(): Page {
|
||||||
@@ -93,61 +146,6 @@ export class AppModeHelper {
|
|||||||
await this.toggleAppMode()
|
await this.toggleAppMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The "Connect an output" popover shown when saving without outputs. */
|
|
||||||
get connectOutputPopover(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The empty-state placeholder shown when no outputs are selected. */
|
|
||||||
get outputPlaceholder(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.outputPlaceholder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The linear-mode widget list container (visible in app mode). */
|
|
||||||
get linearWidgets(): Locator {
|
|
||||||
return this.page.locator('[data-testid="linear-widgets"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
|
||||||
get imagePickerPopover(): Locator {
|
|
||||||
return this.page
|
|
||||||
.getByRole('dialog')
|
|
||||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
|
||||||
.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The Run button in the app mode footer. */
|
|
||||||
get runButton(): Locator {
|
|
||||||
return this.page
|
|
||||||
.getByTestId('linear-run-button')
|
|
||||||
.getByRole('button', { name: /run/i })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
|
||||||
get welcome(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.appMode.welcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The empty workflow message shown when no nodes exist. */
|
|
||||||
get emptyWorkflowText(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.appMode.emptyWorkflow)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
|
||||||
get buildAppButton(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.appMode.buildApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The "Back to workflow" button on the welcome screen. */
|
|
||||||
get backToWorkflowButton(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.appMode.backToWorkflow)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The "Load template" button shown when no nodes exist. */
|
|
||||||
get loadTemplateButton(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.appMode.loadTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||||
|
|||||||
@@ -4,48 +4,32 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|||||||
import { TestIds } from '@e2e/fixtures/selectors'
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
|
|
||||||
export class BuilderFooterHelper {
|
export class BuilderFooterHelper {
|
||||||
constructor(private readonly comfyPage: ComfyPage) {}
|
public readonly nav: Locator
|
||||||
|
public readonly exitButton: Locator
|
||||||
|
public readonly nextButton: Locator
|
||||||
|
public readonly backButton: Locator
|
||||||
|
public readonly saveButton: Locator
|
||||||
|
public readonly saveGroup: Locator
|
||||||
|
public readonly saveAsButton: Locator
|
||||||
|
public readonly saveAsChevron: Locator
|
||||||
|
public readonly opensAsPopover: Locator
|
||||||
|
|
||||||
|
constructor(private readonly comfyPage: ComfyPage) {
|
||||||
|
this.nav = this.page.getByTestId(TestIds.builder.footerNav)
|
||||||
|
this.exitButton = this.buttonByName('Exit app builder')
|
||||||
|
this.nextButton = this.buttonByName('Next')
|
||||||
|
this.backButton = this.buttonByName('Back')
|
||||||
|
this.saveButton = this.page.getByTestId(TestIds.builder.saveButton)
|
||||||
|
this.saveGroup = this.page.getByTestId(TestIds.builder.saveGroup)
|
||||||
|
this.saveAsButton = this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||||
|
this.saveAsChevron = this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||||
|
this.opensAsPopover = this.page.getByTestId(TestIds.builder.opensAs)
|
||||||
|
}
|
||||||
|
|
||||||
private get page(): Page {
|
private get page(): Page {
|
||||||
return this.comfyPage.page
|
return this.comfyPage.page
|
||||||
}
|
}
|
||||||
|
|
||||||
get nav(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.footerNav)
|
|
||||||
}
|
|
||||||
|
|
||||||
get exitButton(): Locator {
|
|
||||||
return this.buttonByName('Exit app builder')
|
|
||||||
}
|
|
||||||
|
|
||||||
get nextButton(): Locator {
|
|
||||||
return this.buttonByName('Next')
|
|
||||||
}
|
|
||||||
|
|
||||||
get backButton(): Locator {
|
|
||||||
return this.buttonByName('Back')
|
|
||||||
}
|
|
||||||
|
|
||||||
get saveButton(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
|
||||||
}
|
|
||||||
|
|
||||||
get saveGroup(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.saveGroup)
|
|
||||||
}
|
|
||||||
|
|
||||||
get saveAsButton(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
|
||||||
}
|
|
||||||
|
|
||||||
get saveAsChevron(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.saveAsChevron)
|
|
||||||
}
|
|
||||||
|
|
||||||
get opensAsPopover(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private buttonByName(name: string): Locator {
|
private buttonByName(name: string): Locator {
|
||||||
return this.nav.getByRole('button', { name })
|
return this.nav.getByRole('button', { name })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,73 +3,61 @@ import type { Locator, Page } from '@playwright/test'
|
|||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
export class BuilderSaveAsHelper {
|
export class BuilderSaveAsHelper {
|
||||||
constructor(private readonly comfyPage: ComfyPage) {}
|
/** The save-as dialog (scoped by aria-labelledby). */
|
||||||
|
public readonly dialog: Locator
|
||||||
|
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||||
|
public readonly successDialog: Locator
|
||||||
|
public readonly title: Locator
|
||||||
|
public readonly radioGroup: Locator
|
||||||
|
public readonly nameInput: Locator
|
||||||
|
public readonly saveButton: Locator
|
||||||
|
public readonly successMessage: Locator
|
||||||
|
public readonly viewAppButton: Locator
|
||||||
|
public readonly closeButton: Locator
|
||||||
|
/** The X button to dismiss the success dialog without any action. */
|
||||||
|
public readonly dismissButton: Locator
|
||||||
|
public readonly exitBuilderButton: Locator
|
||||||
|
public readonly overwriteDialog: Locator
|
||||||
|
public readonly overwriteButton: Locator
|
||||||
|
|
||||||
|
constructor(private readonly comfyPage: ComfyPage) {
|
||||||
|
this.dialog = this.page.locator('[aria-labelledby="builder-save"]')
|
||||||
|
this.successDialog = this.page.locator(
|
||||||
|
'[aria-labelledby="builder-save-success"]'
|
||||||
|
)
|
||||||
|
this.title = this.dialog.getByText('Save as')
|
||||||
|
this.radioGroup = this.dialog.getByRole('radiogroup')
|
||||||
|
this.nameInput = this.dialog.getByRole('textbox')
|
||||||
|
this.saveButton = this.dialog.getByRole('button', { name: 'Save' })
|
||||||
|
this.successMessage = this.successDialog.getByText('Successfully saved')
|
||||||
|
this.viewAppButton = this.successDialog.getByRole('button', {
|
||||||
|
name: 'View app'
|
||||||
|
})
|
||||||
|
this.closeButton = this.successDialog
|
||||||
|
.getByRole('button', { name: 'Close', exact: true })
|
||||||
|
.filter({ hasText: 'Close' })
|
||||||
|
this.dismissButton = this.successDialog.locator(
|
||||||
|
'button.p-dialog-close-button'
|
||||||
|
)
|
||||||
|
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||||
|
name: 'Exit builder'
|
||||||
|
})
|
||||||
|
this.overwriteDialog = this.page.getByRole('dialog', {
|
||||||
|
name: 'Overwrite existing file?'
|
||||||
|
})
|
||||||
|
this.overwriteButton = this.overwriteDialog.getByRole('button', {
|
||||||
|
name: 'Overwrite'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private get page(): Page {
|
private get page(): Page {
|
||||||
return this.comfyPage.page
|
return this.comfyPage.page
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The save-as dialog (scoped by aria-labelledby). */
|
|
||||||
get dialog(): Locator {
|
|
||||||
return this.page.locator('[aria-labelledby="builder-save"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
|
||||||
get successDialog(): Locator {
|
|
||||||
return this.page.locator('[aria-labelledby="builder-save-success"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
get title(): Locator {
|
|
||||||
return this.dialog.getByText('Save as')
|
|
||||||
}
|
|
||||||
|
|
||||||
get radioGroup(): Locator {
|
|
||||||
return this.dialog.getByRole('radiogroup')
|
|
||||||
}
|
|
||||||
|
|
||||||
get nameInput(): Locator {
|
|
||||||
return this.dialog.getByRole('textbox')
|
|
||||||
}
|
|
||||||
|
|
||||||
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
||||||
return this.dialog.getByRole('radio', { name: viewType })
|
return this.dialog.getByRole('radio', { name: viewType })
|
||||||
}
|
}
|
||||||
|
|
||||||
get saveButton(): Locator {
|
|
||||||
return this.dialog.getByRole('button', { name: 'Save' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get successMessage(): Locator {
|
|
||||||
return this.successDialog.getByText('Successfully saved')
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewAppButton(): Locator {
|
|
||||||
return this.successDialog.getByRole('button', { name: 'View app' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get closeButton(): Locator {
|
|
||||||
return this.successDialog
|
|
||||||
.getByRole('button', { name: 'Close', exact: true })
|
|
||||||
.filter({ hasText: 'Close' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The X button to dismiss the success dialog without any action. */
|
|
||||||
get dismissButton(): Locator {
|
|
||||||
return this.successDialog.locator('button.p-dialog-close-button')
|
|
||||||
}
|
|
||||||
|
|
||||||
get exitBuilderButton(): Locator {
|
|
||||||
return this.successDialog.getByRole('button', { name: 'Exit builder' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get overwriteDialog(): Locator {
|
|
||||||
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
|
|
||||||
}
|
|
||||||
|
|
||||||
get overwriteButton(): Locator {
|
|
||||||
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
||||||
await this.nameInput.fill(workflowName)
|
await this.nameInput.fill(workflowName)
|
||||||
await this.viewTypeRadio(viewType).click()
|
await this.viewTypeRadio(viewType).click()
|
||||||
|
|||||||
@@ -32,7 +32,20 @@ async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BuilderSelectHelper {
|
export class BuilderSelectHelper {
|
||||||
constructor(private readonly comfyPage: ComfyPage) {}
|
/** All IoItem locators in the current step sidebar. */
|
||||||
|
public readonly inputItems: Locator
|
||||||
|
/** All IoItem title locators in the inputs step sidebar. */
|
||||||
|
public readonly inputItemTitles: Locator
|
||||||
|
/** All widget label locators in the preview/arrange sidebar. */
|
||||||
|
public readonly previewWidgetLabels: Locator
|
||||||
|
|
||||||
|
constructor(private readonly comfyPage: ComfyPage) {
|
||||||
|
this.inputItems = this.page.getByTestId(TestIds.builder.ioItem)
|
||||||
|
this.inputItemTitles = this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||||
|
this.previewWidgetLabels = this.page.getByTestId(
|
||||||
|
TestIds.builder.widgetLabel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private get page(): Page {
|
private get page(): Page {
|
||||||
return this.comfyPage.page
|
return this.comfyPage.page
|
||||||
@@ -43,12 +56,9 @@ export class BuilderSelectHelper {
|
|||||||
* @param title The widget title shown in the IoItem.
|
* @param title The widget title shown in the IoItem.
|
||||||
*/
|
*/
|
||||||
getInputItemMenu(title: string): Locator {
|
getInputItemMenu(title: string): Locator {
|
||||||
return this.page
|
return this.inputItems
|
||||||
.getByTestId(TestIds.builder.ioItem)
|
|
||||||
.filter({
|
.filter({
|
||||||
has: this.page
|
has: this.inputItemTitles.getByText(title, { exact: true })
|
||||||
.getByTestId(TestIds.builder.ioItemTitle)
|
|
||||||
.getByText(title, { exact: true })
|
|
||||||
})
|
})
|
||||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||||
}
|
}
|
||||||
@@ -141,6 +151,7 @@ export class BuilderSelectHelper {
|
|||||||
const widgetLocator = this.comfyPage.vueNodes
|
const widgetLocator = this.comfyPage.vueNodes
|
||||||
.getNodeLocator(String(nodeRef.id))
|
.getNodeLocator(String(nodeRef.id))
|
||||||
.getByLabel(widgetName, { exact: true })
|
.getByLabel(widgetName, { exact: true })
|
||||||
|
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||||
await widgetLocator.click({ force: true })
|
await widgetLocator.click({ force: true })
|
||||||
await this.comfyPage.nextFrame()
|
await this.comfyPage.nextFrame()
|
||||||
}
|
}
|
||||||
@@ -150,38 +161,19 @@ export class BuilderSelectHelper {
|
|||||||
* Useful for asserting "Widget not visible" on disconnected inputs.
|
* Useful for asserting "Widget not visible" on disconnected inputs.
|
||||||
*/
|
*/
|
||||||
getInputItemSubtitle(title: string): Locator {
|
getInputItemSubtitle(title: string): Locator {
|
||||||
return this.page
|
return this.inputItems
|
||||||
.getByTestId(TestIds.builder.ioItem)
|
|
||||||
.filter({
|
.filter({
|
||||||
has: this.page
|
has: this.inputItemTitles.getByText(title, { exact: true })
|
||||||
.getByTestId(TestIds.builder.ioItemTitle)
|
|
||||||
.getByText(title, { exact: true })
|
|
||||||
})
|
})
|
||||||
.getByTestId(TestIds.builder.ioItemSubtitle)
|
.getByTestId(TestIds.builder.ioItemSubtitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All IoItem locators in the current step sidebar. */
|
|
||||||
get inputItems(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.ioItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All IoItem title locators in the inputs step sidebar. */
|
|
||||||
get inputItemTitles(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All widget label locators in the preview/arrange sidebar. */
|
|
||||||
get previewWidgetLabels(): Locator {
|
|
||||||
return this.page.getByTestId(TestIds.builder.widgetLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drag an IoItem from one index to another in the inputs step.
|
* Drag an IoItem from one index to another in the inputs step.
|
||||||
* Items are identified by their 0-based position among visible IoItems.
|
* Items are identified by their 0-based position among visible IoItems.
|
||||||
*/
|
*/
|
||||||
async dragInputItem(fromIndex: number, toIndex: number) {
|
async dragInputItem(fromIndex: number, toIndex: number) {
|
||||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
await dragByIndex(this.inputItems, fromIndex, toIndex)
|
||||||
await dragByIndex(items, fromIndex, toIndex)
|
|
||||||
await this.comfyPage.nextFrame()
|
await this.comfyPage.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +200,7 @@ export class BuilderSelectHelper {
|
|||||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||||
String(nodeRef.id)
|
String(nodeRef.id)
|
||||||
)
|
)
|
||||||
|
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||||
await nodeLocator.click({ force: true })
|
await nodeLocator.click({ force: true })
|
||||||
await this.comfyPage.nextFrame()
|
await this.comfyPage.nextFrame()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import type { Locator, Page } from '@playwright/test'
|
|||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
export class BuilderStepsHelper {
|
export class BuilderStepsHelper {
|
||||||
constructor(private readonly comfyPage: ComfyPage) {}
|
public readonly toolbar: Locator
|
||||||
|
|
||||||
|
constructor(private readonly comfyPage: ComfyPage) {
|
||||||
|
this.toolbar = this.page.getByRole('navigation', { name: 'App Builder' })
|
||||||
|
}
|
||||||
|
|
||||||
private get page(): Page {
|
private get page(): Page {
|
||||||
return this.comfyPage.page
|
return this.comfyPage.page
|
||||||
}
|
}
|
||||||
|
|
||||||
get toolbar(): Locator {
|
|
||||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async goToInputs() {
|
async goToInputs() {
|
||||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||||
await this.comfyPage.nextFrame()
|
await this.comfyPage.nextFrame()
|
||||||
|
|||||||
@@ -74,6 +74,51 @@ export class CanvasHelper {
|
|||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a canvas-element-relative position to absolute page coordinates.
|
||||||
|
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||||
|
* cause Playwright's actionability check to fail on the canvas locator.
|
||||||
|
*/
|
||||||
|
private async toAbsolute(position: Position): Promise<Position> {
|
||||||
|
const box = await this.canvas.boundingBox()
|
||||||
|
if (!box) throw new Error('Canvas bounding box not available')
|
||||||
|
return { x: box.x + position.x, y: box.y + position.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
|
||||||
|
* Bypasses Playwright's actionability checks on the canvas locator, which
|
||||||
|
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
|
||||||
|
*/
|
||||||
|
async mouseClickAt(
|
||||||
|
position: Position,
|
||||||
|
options?: {
|
||||||
|
button?: 'left' | 'right' | 'middle'
|
||||||
|
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const abs = await this.toAbsolute(position)
|
||||||
|
const modifiers = options?.modifiers ?? []
|
||||||
|
for (const mod of modifiers) await this.page.keyboard.down(mod)
|
||||||
|
try {
|
||||||
|
await this.page.mouse.click(abs.x, abs.y, {
|
||||||
|
button: options?.button
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||||
|
}
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double-click at canvas-element-relative coordinates using `page.mouse`.
|
||||||
|
*/
|
||||||
|
async mouseDblclickAt(position: Position): Promise<void> {
|
||||||
|
const abs = await this.toAbsolute(position)
|
||||||
|
await this.page.mouse.dblclick(abs.x, abs.y)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
async clickEmptySpace(): Promise<void> {
|
async clickEmptySpace(): Promise<void> {
|
||||||
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
|
|||||||
211
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
211
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import type { WebSocketRoute } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import type { ComfyPage } from '../ComfyPage'
|
||||||
|
import { createMockJob } from './AssetsHelper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for simulating prompt execution in e2e tests.
|
||||||
|
*/
|
||||||
|
export class ExecutionHelper {
|
||||||
|
private jobCounter = 0
|
||||||
|
private readonly completedJobs: RawJobListItem[] = []
|
||||||
|
private readonly page: ComfyPage['page']
|
||||||
|
private readonly command: ComfyPage['command']
|
||||||
|
private readonly assets: ComfyPage['assets']
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
private readonly ws: WebSocketRoute
|
||||||
|
) {
|
||||||
|
this.page = comfyPage.page
|
||||||
|
this.command = comfyPage.command
|
||||||
|
this.assets = comfyPage.assets
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
|
||||||
|
* the synthetic job ID.
|
||||||
|
*
|
||||||
|
* The app receives a valid PromptResponse so storeJob() fires
|
||||||
|
* and registers the job against the active workflow path.
|
||||||
|
*/
|
||||||
|
async run(): Promise<string> {
|
||||||
|
const jobId = `test-job-${++this.jobCounter}`
|
||||||
|
|
||||||
|
let fulfilled!: () => void
|
||||||
|
const prompted = new Promise<void>((r) => {
|
||||||
|
fulfilled = r
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.page.route(
|
||||||
|
'**/api/prompt',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt_id: jobId,
|
||||||
|
node_errors: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
fulfilled()
|
||||||
|
},
|
||||||
|
{ times: 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.command.executeCommand('Comfy.QueuePrompt')
|
||||||
|
await prompted
|
||||||
|
|
||||||
|
return jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a binary `b_preview_with_metadata` WS message (type 4).
|
||||||
|
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
|
||||||
|
*/
|
||||||
|
latentPreview(jobId: string, nodeId: string): void {
|
||||||
|
const metadata = JSON.stringify({
|
||||||
|
node_id: nodeId,
|
||||||
|
display_node_id: nodeId,
|
||||||
|
parent_node_id: nodeId,
|
||||||
|
real_node_id: nodeId,
|
||||||
|
prompt_id: jobId,
|
||||||
|
image_type: 'image/png'
|
||||||
|
})
|
||||||
|
const metadataBytes = new TextEncoder().encode(metadata)
|
||||||
|
|
||||||
|
// 1x1 red PNG
|
||||||
|
const png = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
|
||||||
|
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
|
||||||
|
const view = new DataView(buf)
|
||||||
|
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
|
||||||
|
view.setUint32(4, metadataBytes.length)
|
||||||
|
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||||
|
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||||
|
|
||||||
|
this.ws.send(Buffer.from(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `execution_start` WS event. */
|
||||||
|
executionStart(jobId: string): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'execution_start',
|
||||||
|
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `executing` WS event to signal which node is currently running. */
|
||||||
|
executing(jobId: string, nodeId: string | null): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'executing',
|
||||||
|
data: { prompt_id: jobId, node: nodeId }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `executed` WS event with node output. */
|
||||||
|
executed(
|
||||||
|
jobId: string,
|
||||||
|
nodeId: string,
|
||||||
|
output: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'executed',
|
||||||
|
data: {
|
||||||
|
prompt_id: jobId,
|
||||||
|
node: nodeId,
|
||||||
|
display_node: nodeId,
|
||||||
|
output
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `execution_success` WS event. */
|
||||||
|
executionSuccess(jobId: string): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'execution_success',
|
||||||
|
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `execution_error` WS event. */
|
||||||
|
executionError(jobId: string, nodeId: string, message: string): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'execution_error',
|
||||||
|
data: {
|
||||||
|
prompt_id: jobId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
node_id: nodeId,
|
||||||
|
node_type: 'Unknown',
|
||||||
|
exception_message: message,
|
||||||
|
exception_type: 'RuntimeError',
|
||||||
|
traceback: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `progress` WS event. */
|
||||||
|
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'progress',
|
||||||
|
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a job by adding it to mock history, sending execution_success,
|
||||||
|
* and triggering a history refresh via a status event.
|
||||||
|
*
|
||||||
|
* Requires an {@link AssetsHelper} to be passed in the constructor.
|
||||||
|
*/
|
||||||
|
async completeWithHistory(
|
||||||
|
jobId: string,
|
||||||
|
nodeId: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<void> {
|
||||||
|
this.completedJobs.push(
|
||||||
|
createMockJob({
|
||||||
|
id: jobId,
|
||||||
|
preview_output: {
|
||||||
|
filename,
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output',
|
||||||
|
nodeId,
|
||||||
|
mediaType: 'images'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.assets.mockOutputHistory(this.completedJobs)
|
||||||
|
this.executionSuccess(jobId)
|
||||||
|
// Trigger queue/history refresh
|
||||||
|
this.status(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `status` WS event to update queue count. */
|
||||||
|
status(queueRemaining: number): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'status',
|
||||||
|
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
browser_tests/fixtures/helpers/Load3DFixtures.ts
Normal file
25
browser_tests/fixtures/helpers/Load3DFixtures.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||||
|
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
|
||||||
|
import { Load3DViewerHelper } from '@e2e/tests/load3d/Load3DViewerHelper'
|
||||||
|
|
||||||
|
export const load3dTest = comfyPageFixture.extend<{
|
||||||
|
load3d: Load3DHelper
|
||||||
|
}>({
|
||||||
|
load3d: async ({ comfyPage }, use) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||||
|
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
|
||||||
|
await comfyPage.vueNodes.waitForNodes()
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await use(new Load3DHelper(node))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const load3dViewerTest = load3dTest.extend<{
|
||||||
|
viewer: Load3DViewerHelper
|
||||||
|
}>({
|
||||||
|
viewer: async ({ comfyPage }, use) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.Load3D.3DViewerEnable', true)
|
||||||
|
await use(new Load3DViewerHelper(comfyPage.page))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { Locator } from '@playwright/test'
|
import type { Locator } from '@playwright/test'
|
||||||
|
|
||||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type {
|
||||||
|
GraphAddOptions,
|
||||||
|
LGraph,
|
||||||
|
LGraphNode
|
||||||
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||||
@@ -8,7 +12,13 @@ import type { Position, Size } from '@e2e/fixtures/types'
|
|||||||
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||||
|
|
||||||
export class NodeOperationsHelper {
|
export class NodeOperationsHelper {
|
||||||
constructor(private comfyPage: ComfyPage) {}
|
public readonly promptDialogInput: Locator
|
||||||
|
|
||||||
|
constructor(private comfyPage: ComfyPage) {
|
||||||
|
this.promptDialogInput = this.page.locator(
|
||||||
|
'.p-dialog-content input[type="text"]'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private get page() {
|
private get page() {
|
||||||
return this.comfyPage.page
|
return this.comfyPage.page
|
||||||
@@ -30,6 +40,45 @@ export class NodeOperationsHelper {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
const selected = window.app?.canvas?.selected_nodes
|
||||||
|
if (!selected) return []
|
||||||
|
return Object.keys(selected).map(Number)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a node to the graph by type.
|
||||||
|
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
|
||||||
|
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
|
||||||
|
* true and cursorPosition is provided, a synthetic MouseEvent is created
|
||||||
|
* as the dragEvent.
|
||||||
|
* @param cursorPosition - Client coordinates for ghost placement dragEvent
|
||||||
|
*/
|
||||||
|
async addNode(
|
||||||
|
type: string,
|
||||||
|
options?: Omit<GraphAddOptions, 'dragEvent'>,
|
||||||
|
cursorPosition?: Position
|
||||||
|
): Promise<NodeReference> {
|
||||||
|
const id = await this.page.evaluate(
|
||||||
|
([nodeType, opts, cursor]) => {
|
||||||
|
const node = window.LiteGraph!.createNode(nodeType)!
|
||||||
|
const addOpts: Record<string, unknown> = { ...opts }
|
||||||
|
if (opts?.ghost && cursor) {
|
||||||
|
addOpts.dragEvent = new MouseEvent('click', {
|
||||||
|
clientX: cursor.x,
|
||||||
|
clientY: cursor.y
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.app!.graph.add(node, addOpts as GraphAddOptions)
|
||||||
|
return node.id
|
||||||
|
},
|
||||||
|
[type, options ?? {}, cursorPosition ?? null] as const
|
||||||
|
)
|
||||||
|
return new NodeReference(id, this.comfyPage)
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove all nodes from the graph and clean. */
|
/** Remove all nodes from the graph and clean. */
|
||||||
async clearGraph() {
|
async clearGraph() {
|
||||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||||
@@ -155,10 +204,6 @@ export class NodeOperationsHelper {
|
|||||||
await this.comfyPage.nextFrame()
|
await this.comfyPage.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
get promptDialogInput(): Locator {
|
|
||||||
return this.page.locator('.p-dialog-content input[type="text"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillPromptDialog(value: string): Promise<void> {
|
async fillPromptDialog(value: string): Promise<void> {
|
||||||
await this.promptDialogInput.fill(value)
|
await this.promptDialogInput.fill(value)
|
||||||
await this.page.keyboard.press('Enter')
|
await this.page.keyboard.press('Enter')
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export class SubgraphHelper {
|
|||||||
|
|
||||||
// Wait for the appropriate UI element to appear
|
// Wait for the appropriate UI element to appear
|
||||||
if (action === 'rightClick') {
|
if (action === 'rightClick') {
|
||||||
await this.page.waitForSelector('.litemenu-entry', {
|
await this.page.locator('.litemenu-entry').first().waitFor({
|
||||||
state: 'visible',
|
state: 'visible',
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
})
|
})
|
||||||
@@ -445,7 +445,7 @@ export class SubgraphHelper {
|
|||||||
await this.rightClickOutputSlot(slotName)
|
await this.rightClickOutputSlot(slotName)
|
||||||
}
|
}
|
||||||
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||||
await this.comfyPage.nextFrame()
|
await this.comfyPage.contextMenu.waitForHidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSubgraphNodeId(): Promise<string> {
|
async findSubgraphNodeId(): Promise<string> {
|
||||||
|
|||||||
@@ -2,20 +2,12 @@ import { expect } from '@playwright/test'
|
|||||||
import type { Locator, Page } from '@playwright/test'
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
export class ToastHelper {
|
export class ToastHelper {
|
||||||
constructor(private readonly page: Page) {}
|
public readonly visibleToasts: Locator
|
||||||
|
public readonly toastErrors: Locator
|
||||||
|
|
||||||
get visibleToasts(): Locator {
|
constructor(private readonly page: Page) {
|
||||||
return this.page.locator('.p-toast-message:visible')
|
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||||
}
|
this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
|
||||||
|
|
||||||
async getToastErrorCount(): Promise<number> {
|
|
||||||
return await this.page
|
|
||||||
.locator('.p-toast-message.p-toast-message-error')
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
async getVisibleToastCount(): Promise<number> {
|
|
||||||
return await this.visibleToasts.count()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeToasts(requireCount = 0): Promise<void> {
|
async closeToasts(requireCount = 0): Promise<void> {
|
||||||
@@ -34,6 +26,6 @@ export class ToastHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assert all toasts are closed
|
// Assert all toasts are closed
|
||||||
await expect(this.visibleToasts).toHaveCount(0, { timeout: 1000 })
|
await expect(this.visibleToasts).toHaveCount(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const TestIds = {
|
|||||||
contextMenu: 'canvas-context-menu',
|
contextMenu: 'canvas-context-menu',
|
||||||
toggleMinimapButton: 'toggle-minimap-button',
|
toggleMinimapButton: 'toggle-minimap-button',
|
||||||
closeMinimapButton: 'close-minimap-button',
|
closeMinimapButton: 'close-minimap-button',
|
||||||
|
minimapContainer: 'minimap-container',
|
||||||
|
minimapCanvas: 'minimap-canvas',
|
||||||
|
minimapViewport: 'minimap-viewport',
|
||||||
|
minimapInteractionOverlay: 'minimap-interaction-overlay',
|
||||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||||
zoomControlsButton: 'zoom-controls-button',
|
zoomControlsButton: 'zoom-controls-button',
|
||||||
zoomInAction: 'zoom-in-action',
|
zoomInAction: 'zoom-in-action',
|
||||||
@@ -130,6 +134,24 @@ export const TestIds = {
|
|||||||
outputPlaceholder: 'builder-output-placeholder',
|
outputPlaceholder: 'builder-output-placeholder',
|
||||||
connectOutputPopover: 'builder-connect-output-popover'
|
connectOutputPopover: 'builder-connect-output-popover'
|
||||||
},
|
},
|
||||||
|
outputHistory: {
|
||||||
|
outputs: 'linear-outputs',
|
||||||
|
welcome: 'linear-welcome',
|
||||||
|
outputInfo: 'linear-output-info',
|
||||||
|
activeQueue: 'linear-job',
|
||||||
|
queueBadge: 'linear-job-badge',
|
||||||
|
inProgressItem: 'linear-in-progress-item',
|
||||||
|
historyItem: 'linear-history-item',
|
||||||
|
skeleton: 'linear-skeleton',
|
||||||
|
latentPreview: 'linear-latent-preview',
|
||||||
|
imageOutput: 'linear-image-output',
|
||||||
|
videoOutput: 'linear-video-output',
|
||||||
|
cancelRun: 'linear-cancel-run',
|
||||||
|
headerProgressBar: 'linear-header-progress-bar',
|
||||||
|
itemProgressBar: 'linear-item-progress-bar',
|
||||||
|
progressOverall: 'linear-progress-overall',
|
||||||
|
progressNode: 'linear-progress-node'
|
||||||
|
},
|
||||||
appMode: {
|
appMode: {
|
||||||
widgetItem: 'app-mode-widget-item',
|
widgetItem: 'app-mode-widget-item',
|
||||||
welcome: 'linear-welcome',
|
welcome: 'linear-welcome',
|
||||||
@@ -155,6 +177,12 @@ export const TestIds = {
|
|||||||
errors: {
|
errors: {
|
||||||
imageLoadError: 'error-loading-image',
|
imageLoadError: 'error-loading-image',
|
||||||
videoLoadError: 'error-loading-video'
|
videoLoadError: 'error-loading-video'
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
overlay: 'loading-overlay'
|
||||||
|
},
|
||||||
|
load3dViewer: {
|
||||||
|
sidebar: 'load3d-viewer-sidebar'
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -174,6 +202,7 @@ export type TestIdValue =
|
|||||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||||
|
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
|
||||||
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
||||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||||
| Exclude<
|
| Exclude<
|
||||||
@@ -185,3 +214,5 @@ export type TestIdValue =
|
|||||||
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
|
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
|
||||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||||
|
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
|
||||||
|
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ function makeMatcher<T>(
|
|||||||
) {
|
) {
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const value = await getValue(node)
|
const value = await getValue(node)
|
||||||
const assertion = this.isNot
|
if (this.isNot) {
|
||||||
? expect(value, 'Node is ' + type).not
|
expect(value, 'Node is ' + type).not.toBeTruthy()
|
||||||
: expect(value, 'Node is not ' + type)
|
} else {
|
||||||
assertion.toBeTruthy()
|
expect(value, 'Node is not ' + type).toBeTruthy()
|
||||||
}).toPass({ timeout: 250, ...options })
|
}
|
||||||
|
}).toPass({ timeout: 5000, ...options })
|
||||||
return {
|
return {
|
||||||
pass: !this.isNot,
|
pass: !this.isNot,
|
||||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||||
@@ -30,7 +31,7 @@ export const comfyExpect = expect.extend({
|
|||||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
async toHaveFocus(locator: Locator, options = {}) {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() => locator.evaluate((el) => el === document.activeElement),
|
() => locator.evaluate((el) => el === document.activeElement),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
import type { Page } from '@playwright/test'
|
|
||||||
|
|
||||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||||
@@ -356,7 +355,11 @@ export class NodeReference {
|
|||||||
}
|
}
|
||||||
async click(
|
async click(
|
||||||
position: 'title' | 'collapse',
|
position: 'title' | 'collapse',
|
||||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
options?: {
|
||||||
|
button?: 'left' | 'right' | 'middle'
|
||||||
|
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||||
|
moveMouseToEmptyArea?: boolean
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
let clickPos: Position
|
let clickPos: Position
|
||||||
switch (position) {
|
switch (position) {
|
||||||
@@ -377,12 +380,7 @@ export class NodeReference {
|
|||||||
delete options.moveMouseToEmptyArea
|
delete options.moveMouseToEmptyArea
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.comfyPage.canvas.click({
|
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
|
||||||
...options,
|
|
||||||
position: clickPos,
|
|
||||||
force: true
|
|
||||||
})
|
|
||||||
await this.comfyPage.nextFrame()
|
|
||||||
if (moveMouseToEmptyArea) {
|
if (moveMouseToEmptyArea) {
|
||||||
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
|
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||||
}
|
}
|
||||||
@@ -499,31 +497,18 @@ export class NodeReference {
|
|||||||
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
// Try just clicking the enter button first
|
// Try just clicking the enter button first
|
||||||
await this.comfyPage.canvas.click({
|
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||||
position: { x: 250, y: 250 },
|
|
||||||
force: true
|
|
||||||
})
|
|
||||||
await this.comfyPage.nextFrame()
|
|
||||||
|
|
||||||
await this.comfyPage.canvas.click({
|
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
|
||||||
position: subgraphButtonPos,
|
|
||||||
force: true
|
|
||||||
})
|
|
||||||
await this.comfyPage.nextFrame()
|
|
||||||
|
|
||||||
if (await checkIsInSubgraph()) return
|
if (await checkIsInSubgraph()) return
|
||||||
|
|
||||||
for (const position of clickPositions) {
|
for (const position of clickPositions) {
|
||||||
// Clear any selection first
|
// Clear any selection first
|
||||||
await this.comfyPage.canvas.click({
|
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||||
position: { x: 250, y: 250 },
|
|
||||||
force: true
|
|
||||||
})
|
|
||||||
await this.comfyPage.nextFrame()
|
|
||||||
|
|
||||||
// Double-click to enter subgraph
|
// Double-click to enter subgraph
|
||||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
await this.comfyPage.canvasOps.mouseDblclickAt(position)
|
||||||
await this.comfyPage.nextFrame()
|
|
||||||
|
|
||||||
if (await checkIsInSubgraph()) return
|
if (await checkIsInSubgraph()) return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,38 +4,24 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
|||||||
|
|
||||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||||
export class VueNodeFixture {
|
export class VueNodeFixture {
|
||||||
constructor(private readonly locator: Locator) {}
|
public readonly header: Locator
|
||||||
|
public readonly title: Locator
|
||||||
|
public readonly titleInput: Locator
|
||||||
|
public readonly body: Locator
|
||||||
|
public readonly pinIndicator: Locator
|
||||||
|
public readonly collapseButton: Locator
|
||||||
|
public readonly collapseIcon: Locator
|
||||||
|
public readonly root: Locator
|
||||||
|
|
||||||
get header(): Locator {
|
constructor(private readonly locator: Locator) {
|
||||||
return this.locator.locator('[data-testid^="node-header-"]')
|
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||||
}
|
this.title = locator.getByTestId('node-title')
|
||||||
|
this.titleInput = locator.getByTestId('node-title-input')
|
||||||
get title(): Locator {
|
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||||
return this.locator.locator('[data-testid="node-title"]')
|
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||||
}
|
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||||
|
this.collapseIcon = this.collapseButton.locator('i')
|
||||||
get titleInput(): Locator {
|
this.root = locator
|
||||||
return this.locator.locator('[data-testid="node-title-input"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
get body(): Locator {
|
|
||||||
return this.locator.locator('[data-testid^="node-body-"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
get pinIndicator(): Locator {
|
|
||||||
return this.locator.getByTestId(TestIds.node.pinIndicator)
|
|
||||||
}
|
|
||||||
|
|
||||||
get collapseButton(): Locator {
|
|
||||||
return this.locator.locator('[data-testid="node-collapse-button"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
get collapseIcon(): Locator {
|
|
||||||
return this.collapseButton.locator('i')
|
|
||||||
}
|
|
||||||
|
|
||||||
get root(): Locator {
|
|
||||||
return this.locator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTitle(): Promise<string> {
|
async getTitle(): Promise<string> {
|
||||||
|
|||||||
@@ -1,53 +1,31 @@
|
|||||||
import { test as base } from '@playwright/test'
|
import { test as base } from '@playwright/test'
|
||||||
|
import type { WebSocketRoute } from '@playwright/test'
|
||||||
|
|
||||||
export const webSocketFixture = base.extend<{
|
export const webSocketFixture = base.extend<{
|
||||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
getWebSocket: () => Promise<WebSocketRoute>
|
||||||
}>({
|
}>({
|
||||||
ws: [
|
getWebSocket: [
|
||||||
async ({ page }, use) => {
|
async ({ context }, use) => {
|
||||||
// Each time a page loads, to catch navigations
|
let latest: WebSocketRoute | undefined
|
||||||
page.on('load', async () => {
|
let resolve: ((ws: WebSocketRoute) => void) | undefined
|
||||||
await page.evaluate(function () {
|
|
||||||
// Create a wrapper for WebSocket that stores them globally
|
await context.routeWebSocket(/\/ws/, (ws) => {
|
||||||
// so we can look it up to trigger messages
|
const server = ws.connectToServer()
|
||||||
const store: Record<string, WebSocket> = (window.__ws__ = {})
|
server.onMessage((message) => {
|
||||||
window.WebSocket = class extends window.WebSocket {
|
ws.send(message)
|
||||||
constructor(
|
|
||||||
...rest: ConstructorParameters<typeof window.WebSocket>
|
|
||||||
) {
|
|
||||||
super(...rest)
|
|
||||||
store[this.url] = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
latest = ws
|
||||||
|
resolve?.(ws)
|
||||||
})
|
})
|
||||||
|
|
||||||
await use({
|
await use(() => {
|
||||||
async trigger(data, url) {
|
if (latest) return Promise.resolve(latest)
|
||||||
// Trigger a websocket event on the page
|
return new Promise<WebSocketRoute>((r) => {
|
||||||
await page.evaluate(
|
resolve = r
|
||||||
function ([data, url]) {
|
})
|
||||||
if (!url) {
|
|
||||||
// If no URL specified, use page URL
|
|
||||||
const u = new URL(window.location.href)
|
|
||||||
u.hash = ''
|
|
||||||
u.protocol = 'ws:'
|
|
||||||
u.pathname = '/'
|
|
||||||
url = u.toString() + 'ws'
|
|
||||||
}
|
|
||||||
const ws: WebSocket = window.__ws__![url]
|
|
||||||
ws.dispatchEvent(
|
|
||||||
new MessageEvent('message', {
|
|
||||||
data
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[JSON.stringify(data), url]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
|
|
||||||
{ auto: true }
|
{ auto: true }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -82,11 +82,9 @@ export async function builderSaveAs(
|
|||||||
viewType: 'App' | 'Node graph' = 'App'
|
viewType: 'App' | 'Node graph' = 'App'
|
||||||
) {
|
) {
|
||||||
await appMode.footer.saveAsButton.click()
|
await appMode.footer.saveAsButton.click()
|
||||||
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
await comfyExpect(appMode.saveAs.nameInput).toBeVisible()
|
||||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||||
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({
|
await comfyExpect(appMode.saveAs.successMessage).toBeVisible()
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,3 +122,21 @@ export async function saveAndReopenInAppMode(
|
|||||||
|
|
||||||
await comfyPage.appMode.toggleAppMode()
|
await comfyPage.appMode.toggleAppMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter builder, select the given widgets as inputs + SaveImage as output,
|
||||||
|
* save as an app, and close the success dialog.
|
||||||
|
*
|
||||||
|
* Returns on the builder arrange/preview step.
|
||||||
|
*/
|
||||||
|
export async function createAndSaveApp(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
appName: string,
|
||||||
|
widgetNames: string[] = ['seed']
|
||||||
|
): Promise<void> {
|
||||||
|
await setupBuilder(comfyPage, undefined, widgetNames)
|
||||||
|
await comfyPage.appMode.steps.goToPreview()
|
||||||
|
await builderSaveAs(comfyPage.appMode, appName)
|
||||||
|
await comfyPage.appMode.saveAs.closeButton.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ export class ManageGroupNode {
|
|||||||
await this.footer.getByText('Close').click()
|
await this.footer.getByText('Close').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get selectedNodeTypeSelect(): Locator {
|
||||||
|
return this.header.locator('select').first()
|
||||||
|
}
|
||||||
|
|
||||||
async getSelectedNodeType() {
|
async getSelectedNodeType() {
|
||||||
const select = this.header.locator('select').first()
|
return await this.selectedNodeTypeSelect.inputValue()
|
||||||
return await select.inputValue()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectNode(name: string) {
|
async selectNode(name: string) {
|
||||||
|
|||||||
66
browser_tests/helpers/painter.ts
Normal file
66
browser_tests/helpers/painter.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||||
|
|
||||||
|
export async function drawStroke(
|
||||||
|
page: Page,
|
||||||
|
canvas: Locator,
|
||||||
|
opts: { startXPct?: number; endXPct?: number; yPct?: number } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const { startXPct = 0.3, endXPct = 0.7, yPct = 0.5 } = opts
|
||||||
|
const box = await canvas.boundingBox()
|
||||||
|
if (!box) throw new Error('Canvas bounding box not found')
|
||||||
|
await page.mouse.move(
|
||||||
|
box.x + box.width * startXPct,
|
||||||
|
box.y + box.height * yPct
|
||||||
|
)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(
|
||||||
|
box.x + box.width * endXPct,
|
||||||
|
box.y + box.height * yPct,
|
||||||
|
{ steps: 10 }
|
||||||
|
)
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||||
|
return canvas.evaluate((el: HTMLCanvasElement) => {
|
||||||
|
const ctx = el.getContext('2d')
|
||||||
|
if (!ctx) return false
|
||||||
|
const { data } = ctx.getImageData(0, 0, el.width, el.height)
|
||||||
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
|
if (data[i] > 0) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerSerialization(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const graph = window.graph as TestGraphAccess | undefined
|
||||||
|
if (!graph) {
|
||||||
|
throw new Error(
|
||||||
|
'Global window.graph is absent. Ensure workflow fixture is loaded.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = graph._nodes_by_id?.['1']
|
||||||
|
if (!node) {
|
||||||
|
throw new Error(
|
||||||
|
'Target node with ID "1" not found in graph._nodes_by_id.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = node.widgets?.find((w) => w.name === 'mask')
|
||||||
|
if (!widget) {
|
||||||
|
throw new Error('Widget "mask" not found on target node 1.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof widget.serializeValue !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'mask widget on node 1 does not have a serializeValue function.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await widget.serializeValue(node, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -22,7 +22,24 @@ export async function getPromotedWidgets(
|
|||||||
): Promise<PromotedWidgetEntry[]> {
|
): Promise<PromotedWidgetEntry[]> {
|
||||||
const raw = await comfyPage.page.evaluate((id) => {
|
const raw = await comfyPage.page.evaluate((id) => {
|
||||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||||
return node?.properties?.proxyWidgets ?? []
|
const widgets = node?.widgets ?? []
|
||||||
|
|
||||||
|
// Read the live promoted widget views from the host node instead of the
|
||||||
|
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||||
|
// state during promotion and cleanup flows.
|
||||||
|
return widgets.flatMap((widget) => {
|
||||||
|
if (
|
||||||
|
widget &&
|
||||||
|
typeof widget === 'object' &&
|
||||||
|
'sourceNodeId' in widget &&
|
||||||
|
typeof widget.sourceNodeId === 'string' &&
|
||||||
|
'sourceWidgetName' in widget &&
|
||||||
|
typeof widget.sourceWidgetName === 'string'
|
||||||
|
) {
|
||||||
|
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
}, nodeId)
|
}, nodeId)
|
||||||
|
|
||||||
return normalizePromotedWidgets(raw)
|
return normalizePromotedWidgets(raw)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Response } from '@playwright/test'
|
import type { Response } from '@playwright/test'
|
||||||
import { expect, mergeTests } from '@playwright/test'
|
import { expect, mergeTests } from '@playwright/test'
|
||||||
|
|
||||||
import type { StatusWsMessage } from '@/schemas/apiSchema'
|
|
||||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||||
@@ -18,14 +17,16 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
|||||||
*/
|
*/
|
||||||
test('Does not auto-queue multiple changes at a time', async ({
|
test('Does not auto-queue multiple changes at a time', async ({
|
||||||
comfyPage,
|
comfyPage,
|
||||||
ws
|
getWebSocket
|
||||||
}) => {
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
|
||||||
// Enable change auto-queue mode
|
// Enable change auto-queue mode
|
||||||
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||||
expect(await queueOpts.getMode()).toBe('disabled')
|
await expect.poll(() => queueOpts.getMode()).toBe('disabled')
|
||||||
await queueOpts.setMode('change')
|
await queueOpts.setMode('change')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
expect(await queueOpts.getMode()).toBe('change')
|
await expect.poll(() => queueOpts.getMode()).toBe('change')
|
||||||
await comfyPage.actionbar.queueButton.toggleOptions()
|
await comfyPage.actionbar.queueButton.toggleOptions()
|
||||||
|
|
||||||
// Intercept the prompt queue endpoint
|
// Intercept the prompt queue endpoint
|
||||||
@@ -62,17 +63,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger a status websocket message
|
// Trigger a status websocket message
|
||||||
const triggerStatus = async (queueSize: number) => {
|
const triggerStatus = (queueSize: number) => {
|
||||||
await ws.trigger({
|
ws.send(
|
||||||
type: 'status',
|
JSON.stringify({
|
||||||
data: {
|
type: 'status',
|
||||||
status: {
|
data: {
|
||||||
exec_info: {
|
status: {
|
||||||
queue_remaining: queueSize
|
exec_info: {
|
||||||
|
queue_remaining: queueSize
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} as StatusWsMessage)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the width from the queue response
|
// Extract the width from the queue response
|
||||||
@@ -104,8 +107,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
|||||||
).toBe(1)
|
).toBe(1)
|
||||||
|
|
||||||
// Trigger a status update so auto-queue re-runs
|
// Trigger a status update so auto-queue re-runs
|
||||||
await triggerStatus(1)
|
triggerStatus(1)
|
||||||
await triggerStatus(0)
|
triggerStatus(0)
|
||||||
|
|
||||||
// Ensure the queued width is the last queued value
|
// Ensure the queued width is the last queued value
|
||||||
expect(
|
expect(
|
||||||
@@ -124,6 +127,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
|||||||
force: true
|
force: true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
expect(await comfyPage.actionbar.isDocked()).toBe(true)
|
await expect(comfyPage.actionbar.root.locator('.actionbar')).toHaveClass(
|
||||||
|
/static/
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
|||||||
]
|
]
|
||||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||||
|
|
||||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scroll to bottom so the codec widget is at the clipping edge
|
// Scroll to bottom so the codec widget is at the clipping edge
|
||||||
const widgetList = comfyPage.appMode.linearWidgets
|
const widgetList = comfyPage.appMode.linearWidgets
|
||||||
@@ -90,21 +88,25 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
|||||||
await codecSelect.click()
|
await codecSelect.click()
|
||||||
|
|
||||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
await expect(overlay).toBeVisible()
|
||||||
|
|
||||||
const isInViewport = await overlay.evaluate((el) => {
|
await expect
|
||||||
const rect = el.getBoundingClientRect()
|
.poll(() =>
|
||||||
return (
|
overlay.evaluate((el) => {
|
||||||
rect.top >= 0 &&
|
const rect = el.getBoundingClientRect()
|
||||||
rect.left >= 0 &&
|
return (
|
||||||
rect.bottom <= window.innerHeight &&
|
rect.top >= 0 &&
|
||||||
rect.right <= window.innerWidth
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= window.innerHeight &&
|
||||||
|
rect.right <= window.innerWidth
|
||||||
|
)
|
||||||
|
})
|
||||||
)
|
)
|
||||||
})
|
.toBe(true)
|
||||||
expect(isInViewport).toBe(true)
|
|
||||||
|
|
||||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
await expect
|
||||||
expect(isClipped).toBe(false)
|
.poll(() => overlay.evaluate(isClippedByAnyAncestor))
|
||||||
|
.toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||||
@@ -119,9 +121,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
|||||||
]
|
]
|
||||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||||
|
|
||||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scroll to bottom so the image widget is at the clipping edge
|
// Scroll to bottom so the image widget is at the clipping edge
|
||||||
const widgetList = comfyPage.appMode.linearWidgets
|
const widgetList = comfyPage.appMode.linearWidgets
|
||||||
@@ -140,20 +140,24 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
|||||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||||
const popover = comfyPage.appMode.imagePickerPopover
|
const popover = comfyPage.appMode.imagePickerPopover
|
||||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
await expect(popover).toBeVisible()
|
||||||
|
|
||||||
const isInViewport = await popover.evaluate((el) => {
|
await expect
|
||||||
const rect = el.getBoundingClientRect()
|
.poll(() =>
|
||||||
return (
|
popover.evaluate((el) => {
|
||||||
rect.top >= 0 &&
|
const rect = el.getBoundingClientRect()
|
||||||
rect.left >= 0 &&
|
return (
|
||||||
rect.bottom <= window.innerHeight &&
|
rect.top >= 0 &&
|
||||||
rect.right <= window.innerWidth
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= window.innerHeight &&
|
||||||
|
rect.right <= window.innerWidth
|
||||||
|
)
|
||||||
|
})
|
||||||
)
|
)
|
||||||
})
|
.toBe(true)
|
||||||
expect(isInViewport).toBe(true)
|
|
||||||
|
|
||||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
await expect
|
||||||
expect(isClipped).toBe(false)
|
.poll(() => popover.evaluate(isClippedByAnyAncestor))
|
||||||
|
.toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
comfyPageFixture as test,
|
comfyPageFixture as test,
|
||||||
comfyExpect as expect
|
comfyExpect as expect
|
||||||
} from '../fixtures/ComfyPage'
|
} from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
@@ -16,7 +16,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
|
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
|
||||||
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
|
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Build app button is visible when no outputs selected', async ({
|
test('Build app button is visible when no outputs selected', async ({
|
||||||
@@ -26,7 +26,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||||
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
|
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
|
||||||
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
|
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Empty workflow and build app are hidden when app has outputs', async ({
|
test('Empty workflow and build app are hidden when app has outputs', async ({
|
||||||
@@ -35,8 +35,8 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
|||||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||||
|
|
||||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||||
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
|
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||||
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
|
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
|
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
|
||||||
@@ -46,7 +46,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
|||||||
await comfyPage.appMode.backToWorkflowButton.click()
|
await comfyPage.appMode.backToWorkflowButton.click()
|
||||||
|
|
||||||
await expect(comfyPage.canvas).toBeVisible()
|
await expect(comfyPage.canvas).toBeVisible()
|
||||||
await expect(comfyPage.appMode.welcome).not.toBeVisible()
|
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Load template opens template selector', async ({ comfyPage }) => {
|
test('Load template opens template selector', async ({ comfyPage }) => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
|||||||
await appMode.steps.goToInputs()
|
await appMode.steps.goToInputs()
|
||||||
|
|
||||||
const menu = appMode.select.getInputItemMenu('seed')
|
const menu = appMode.select.getInputItemMenu('seed')
|
||||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
await expect(menu).toBeVisible()
|
||||||
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
|
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
|
||||||
|
|
||||||
// Verify in app mode after save/reload
|
// Verify in app mode after save/reload
|
||||||
@@ -34,7 +34,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
|||||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||||
|
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
appMode.linearWidgets.getByText('Builder Input Seed')
|
appMode.linearWidgets.getByText('Builder Input Seed')
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
@@ -54,7 +54,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
|||||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||||
|
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
|||||||
await appMode.steps.goToPreview()
|
await appMode.steps.goToPreview()
|
||||||
|
|
||||||
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
|
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
|
||||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
await expect(menu).toBeVisible()
|
||||||
await appMode.select.renameWidget(menu, 'Preview Seed')
|
await appMode.select.renameWidget(menu, 'Preview Seed')
|
||||||
|
|
||||||
// Verify in app mode after save/reload
|
// Verify in app mode after save/reload
|
||||||
@@ -73,7 +73,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
|||||||
const workflowName = `${new Date().getTime()} builder-preview`
|
const workflowName = `${new Date().getTime()} builder-preview`
|
||||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||||
|
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
|
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
|||||||
await appMode.footer.exitBuilder()
|
await appMode.footer.exitBuilder()
|
||||||
await appMode.toggleAppMode()
|
await appMode.toggleAppMode()
|
||||||
|
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
|
|
||||||
const menu = appMode.getAppModeWidgetMenu('seed')
|
const menu = appMode.getAppModeWidgetMenu('seed')
|
||||||
await appMode.select.renameWidget(menu, 'App Mode Seed')
|
await appMode.select.renameWidget(menu, 'App Mode Seed')
|
||||||
@@ -97,7 +97,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
|||||||
const workflowName = `${new Date().getTime()} app-mode`
|
const workflowName = `${new Date().getTime()} app-mode`
|
||||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||||
|
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ test.describe('App mode widget values in prompt', { tag: '@ui' }, () => {
|
|||||||
({ nodeId, widgetName }) => [nodeId, widgetName]
|
({ nodeId, widgetName }) => [nodeId, widgetName]
|
||||||
)
|
)
|
||||||
await appMode.enterAppModeWithInputs(inputs)
|
await appMode.enterAppModeWithInputs(inputs)
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
|
|
||||||
for (const { nodeId, widgetName, type, fill } of WIDGET_TEST_DATA) {
|
for (const { nodeId, widgetName, type, fill } of WIDGET_TEST_DATA) {
|
||||||
const key = `${nodeId}:${widgetName}`
|
const key = `${nodeId}:${widgetName}`
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
|||||||
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
||||||
const { bottomPanel } = comfyPage
|
const { bottomPanel } = comfyPage
|
||||||
|
|
||||||
await expect(bottomPanel.root).not.toBeVisible()
|
await expect(bottomPanel.root).toBeHidden()
|
||||||
await bottomPanel.toggleButton.click()
|
await bottomPanel.toggleButton.click()
|
||||||
await expect(bottomPanel.root).toBeVisible()
|
await expect(bottomPanel.root).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -35,7 +35,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
|||||||
await expect(bottomPanel.root).toBeVisible()
|
await expect(bottomPanel.root).toBeVisible()
|
||||||
|
|
||||||
await bottomPanel.toggleButton.click()
|
await bottomPanel.toggleButton.click()
|
||||||
await expect(bottomPanel.root).not.toBeVisible()
|
await expect(bottomPanel.root).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should switch between shortcuts and terminal panels', async ({
|
test('should switch between shortcuts and terminal panels', async ({
|
||||||
@@ -55,7 +55,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
|||||||
await expect(logsTab).toBeVisible()
|
await expect(logsTab).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||||
).not.toBeVisible()
|
).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should persist Logs tab content in bottom panel', async ({
|
test('should persist Logs tab content in bottom panel', async ({
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
|||||||
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
|
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
|
||||||
const { bottomPanel } = comfyPage
|
const { bottomPanel } = comfyPage
|
||||||
|
|
||||||
await expect(bottomPanel.root).not.toBeVisible()
|
await expect(bottomPanel.root).toBeHidden()
|
||||||
await bottomPanel.keyboardShortcutsButton.click()
|
await bottomPanel.keyboardShortcutsButton.click()
|
||||||
await expect(bottomPanel.root).toBeVisible()
|
await expect(bottomPanel.root).toBeVisible()
|
||||||
await bottomPanel.keyboardShortcutsButton.click()
|
await bottomPanel.keyboardShortcutsButton.click()
|
||||||
await expect(bottomPanel.root).not.toBeVisible()
|
await expect(bottomPanel.root).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
|
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
|
||||||
@@ -103,14 +103,15 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
const keyBadges = bottomPanel.shortcuts.keyBadges
|
const keyBadges = bottomPanel.shortcuts.keyBadges
|
||||||
await keyBadges.first().waitFor({ state: 'visible' })
|
await keyBadges.first().waitFor({ state: 'visible' })
|
||||||
const count = await keyBadges.count()
|
await expect.poll(() => keyBadges.count()).toBeGreaterThanOrEqual(1)
|
||||||
expect(count).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
const badgeText = await keyBadges.allTextContents()
|
await expect
|
||||||
const hasModifiers = badgeText.some((text) =>
|
.poll(() => keyBadges.allTextContents())
|
||||||
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
|
.toEqual(
|
||||||
)
|
expect.arrayContaining([
|
||||||
expect(hasModifiers).toBeTruthy()
|
expect.stringMatching(/^(Ctrl|Cmd|Shift|Alt)$/)
|
||||||
|
])
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should maintain panel state when switching between panels', async ({
|
test('should maintain panel state when switching between panels', async ({
|
||||||
@@ -181,7 +182,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
|||||||
await expect(bottomPanel.root).toBeVisible()
|
await expect(bottomPanel.root).toBeVisible()
|
||||||
|
|
||||||
await bottomPanel.keyboardShortcutsButton.click()
|
await bottomPanel.keyboardShortcutsButton.click()
|
||||||
await expect(bottomPanel.root).not.toBeVisible()
|
await expect(bottomPanel.root).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should display shortcuts in organized columns', async ({
|
test('should display shortcuts in organized columns', async ({
|
||||||
@@ -191,13 +192,10 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await bottomPanel.keyboardShortcutsButton.click()
|
await bottomPanel.keyboardShortcutsButton.click()
|
||||||
|
|
||||||
await expect(
|
await expect(comfyPage.page.getByTestId('shortcuts-columns')).toBeVisible()
|
||||||
comfyPage.page.locator('[data-testid="shortcuts-columns"]')
|
|
||||||
).toBeVisible()
|
|
||||||
|
|
||||||
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
|
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
|
||||||
const titleCount = await subcategoryTitles.count()
|
await expect.poll(() => subcategoryTitles.count()).toBeGreaterThanOrEqual(2)
|
||||||
expect(titleCount).toBeGreaterThanOrEqual(2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should open shortcuts panel with Ctrl+Shift+K', async ({
|
test('should open shortcuts panel with Ctrl+Shift+K', async ({
|
||||||
@@ -205,7 +203,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const { bottomPanel } = comfyPage
|
const { bottomPanel } = comfyPage
|
||||||
|
|
||||||
await expect(bottomPanel.root).not.toBeVisible()
|
await expect(bottomPanel.root).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
|
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|||||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||||
import {
|
import {
|
||||||
builderSaveAs,
|
builderSaveAs,
|
||||||
|
createAndSaveApp,
|
||||||
openWorkflowFromSidebar,
|
openWorkflowFromSidebar,
|
||||||
setupBuilder
|
setupBuilder
|
||||||
} from '@e2e/helpers/builderTestUtils'
|
} from '@e2e/helpers/builderTestUtils'
|
||||||
@@ -21,7 +22,7 @@ async function saveCloseAndReopenAsApp(
|
|||||||
await appMode.steps.goToPreview()
|
await appMode.steps.goToPreview()
|
||||||
await builderSaveAs(appMode, workflowName)
|
await builderSaveAs(appMode, workflowName)
|
||||||
await appMode.saveAs.closeButton.click()
|
await appMode.saveAs.closeButton.click()
|
||||||
await comfyPage.nextFrame()
|
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||||
|
|
||||||
await appMode.footer.exitBuilder()
|
await appMode.footer.exitBuilder()
|
||||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||||
@@ -122,7 +123,7 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
|||||||
const workflowName = `${Date.now()} reorder-preview`
|
const workflowName = `${Date.now()} reorder-preview`
|
||||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||||
|
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||||
'steps',
|
'steps',
|
||||||
'cfg',
|
'cfg',
|
||||||
@@ -147,11 +148,58 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
|||||||
const workflowName = `${Date.now()} reorder-persist`
|
const workflowName = `${Date.now()} reorder-persist`
|
||||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||||
|
|
||||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
await expect(appMode.linearWidgets).toBeVisible()
|
||||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||||
'steps',
|
'steps',
|
||||||
'cfg',
|
'cfg',
|
||||||
'seed'
|
'seed'
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Reordering inputs in one app does not corrupt another app', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const { appMode } = comfyPage
|
||||||
|
const suffix = String(Date.now())
|
||||||
|
const app1Name = `app1-${suffix}`
|
||||||
|
const app2Name = `app2-${suffix}`
|
||||||
|
const app2Widgets = ['seed', 'steps']
|
||||||
|
|
||||||
|
// Create and save app1 with [seed, steps, cfg]
|
||||||
|
await createAndSaveApp(comfyPage, app1Name, WIDGETS)
|
||||||
|
await appMode.footer.exitBuilder()
|
||||||
|
|
||||||
|
// Create app2 in a new tab so both apps are open simultaneously
|
||||||
|
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||||
|
await createAndSaveApp(comfyPage, app2Name, app2Widgets)
|
||||||
|
await appMode.footer.exitBuilder()
|
||||||
|
|
||||||
|
// Switch to app1 tab and enter builder
|
||||||
|
await comfyPage.menu.topbar.getWorkflowTab(app1Name).click()
|
||||||
|
await appMode.enterBuilder()
|
||||||
|
await appMode.steps.goToInputs()
|
||||||
|
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||||
|
|
||||||
|
// Reorder app1 inputs: drag 'seed' from first to last
|
||||||
|
await appMode.select.dragInputItem(0, 2)
|
||||||
|
const app1Reordered = ['steps', 'cfg', 'seed']
|
||||||
|
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
|
||||||
|
|
||||||
|
// Switch to app2 tab and enter builder
|
||||||
|
await appMode.footer.exitBuilder()
|
||||||
|
await comfyPage.menu.topbar.getWorkflowTab(app2Name).click()
|
||||||
|
await appMode.enterBuilder()
|
||||||
|
await appMode.steps.goToInputs()
|
||||||
|
|
||||||
|
// Verify app2 inputs are not corrupted — still [seed, steps]
|
||||||
|
await expect(appMode.select.inputItemTitles).toHaveText(app2Widgets)
|
||||||
|
|
||||||
|
// Switch back to app1 and verify reorder persisted
|
||||||
|
await appMode.footer.exitBuilder()
|
||||||
|
await comfyPage.menu.topbar.getWorkflowTab(app1Name).click()
|
||||||
|
await appMode.enterBuilder()
|
||||||
|
await appMode.steps.goToInputs()
|
||||||
|
|
||||||
|
await expect(appMode.select.inputItemTitles).toHaveText(app1Reordered)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
comfyExpect as expect
|
comfyExpect as expect
|
||||||
} from '@e2e/fixtures/ComfyPage'
|
} from '@e2e/fixtures/ComfyPage'
|
||||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||||
|
import type { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||||
import {
|
import {
|
||||||
builderSaveAs,
|
builderSaveAs,
|
||||||
openWorkflowFromSidebar,
|
openWorkflowFromSidebar,
|
||||||
@@ -20,10 +21,19 @@ async function reSaveAs(
|
|||||||
viewType: 'App' | 'Node graph'
|
viewType: 'App' | 'Node graph'
|
||||||
) {
|
) {
|
||||||
await appMode.footer.openSaveAsFromChevron()
|
await appMode.footer.openSaveAsFromChevron()
|
||||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
await expect(appMode.saveAs.nameInput).toBeVisible()
|
||||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dismissSuccessDialog(
|
||||||
|
saveAs: BuilderSaveAsHelper,
|
||||||
|
button: 'close' | 'dismiss' = 'close'
|
||||||
|
) {
|
||||||
|
const btn = button === 'close' ? saveAs.closeButton : saveAs.dismissButton
|
||||||
|
await btn.click()
|
||||||
|
await expect(saveAs.successDialog).toBeHidden()
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
await comfyPage.appMode.enableLinearMode()
|
await comfyPage.appMode.enableLinearMode()
|
||||||
@@ -38,7 +48,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
await comfyPage.appMode.footer.saveAsButton.click()
|
await comfyPage.appMode.footer.saveAsButton.click()
|
||||||
|
|
||||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
await expect(saveAs.dialog).toBeVisible()
|
||||||
await expect(saveAs.nameInput).toBeVisible()
|
await expect(saveAs.nameInput).toBeVisible()
|
||||||
await expect(saveAs.title).toBeVisible()
|
await expect(saveAs.title).toBeVisible()
|
||||||
await expect(saveAs.radioGroup).toBeVisible()
|
await expect(saveAs.radioGroup).toBeVisible()
|
||||||
@@ -58,7 +68,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
await comfyPage.appMode.footer.saveAsButton.click()
|
await comfyPage.appMode.footer.saveAsButton.click()
|
||||||
|
|
||||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
await expect(saveAs.dialog).toBeVisible()
|
||||||
await saveAs.nameInput.fill('')
|
await saveAs.nameInput.fill('')
|
||||||
await expect(saveAs.saveButton).toBeDisabled()
|
await expect(saveAs.saveButton).toBeDisabled()
|
||||||
})
|
})
|
||||||
@@ -68,7 +78,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
await comfyPage.appMode.footer.saveAsButton.click()
|
await comfyPage.appMode.footer.saveAsButton.click()
|
||||||
|
|
||||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
await expect(saveAs.dialog).toBeVisible()
|
||||||
|
|
||||||
const appRadio = saveAs.viewTypeRadio('App')
|
const appRadio = saveAs.viewTypeRadio('App')
|
||||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||||
@@ -103,7 +113,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await comfyPage.page.keyboard.press('Escape')
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||||
@@ -111,7 +121,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
|
|
||||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||||
await comfyPage.appMode.footer.exitBuilder()
|
await comfyPage.appMode.footer.exitBuilder()
|
||||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Save button directly saves for previously saved workflow', async ({
|
test('Save button directly saves for previously saved workflow', async ({
|
||||||
@@ -121,18 +131,17 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
|
|
||||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
|
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
|
||||||
await saveAs.closeButton.click()
|
await dismissSuccessDialog(saveAs)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
// Modify the workflow so the save button becomes enabled
|
// Modify the workflow so the save button becomes enabled
|
||||||
await comfyPage.appMode.steps.goToInputs()
|
await comfyPage.appMode.steps.goToInputs()
|
||||||
await comfyPage.appMode.select.deleteInput('seed')
|
await comfyPage.appMode.select.deleteInput('seed')
|
||||||
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
|
await expect(footer.saveButton).toBeEnabled()
|
||||||
|
|
||||||
await footer.saveButton.click()
|
await footer.saveButton.click()
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
await expect(saveAs.dialog).not.toBeVisible({ timeout: 2000 })
|
await expect(saveAs.dialog).toBeHidden()
|
||||||
await expect(footer.saveButton).toBeDisabled()
|
await expect(footer.saveButton).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -143,12 +152,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
|
|
||||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
|
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
|
||||||
await saveAs.closeButton.click()
|
await dismissSuccessDialog(saveAs)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
await footer.openSaveAsFromChevron()
|
await footer.openSaveAsFromChevron()
|
||||||
|
|
||||||
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
|
await expect(saveAs.title).toBeVisible()
|
||||||
await expect(saveAs.nameInput).toBeVisible()
|
await expect(saveAs.nameInput).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -161,8 +169,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await appMode.enterBuilder()
|
await appMode.enterBuilder()
|
||||||
|
|
||||||
// State 1: Disabled "Save as" (no outputs selected)
|
// State 1: Disabled "Save as" (no outputs selected)
|
||||||
|
await expect(appMode.footer.saveAsButton).toBeVisible()
|
||||||
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
|
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||||
expect(disabledBox).toBeTruthy()
|
if (!disabledBox)
|
||||||
|
throw new Error('saveAsButton boundingBox returned null while visible')
|
||||||
|
const disabledWidth = disabledBox.width
|
||||||
|
|
||||||
// Select I/O to enable the button
|
// Select I/O to enable the button
|
||||||
await appMode.steps.goToInputs()
|
await appMode.steps.goToInputs()
|
||||||
@@ -171,19 +182,20 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await appMode.select.selectOutputNode('Save Image')
|
await appMode.select.selectOutputNode('Save Image')
|
||||||
|
|
||||||
// State 2: Enabled "Save as" (unsaved, has outputs)
|
// State 2: Enabled "Save as" (unsaved, has outputs)
|
||||||
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
|
await expect
|
||||||
expect(enabledBox).toBeTruthy()
|
.poll(
|
||||||
expect(enabledBox!.width).toBe(disabledBox!.width)
|
async () => (await appMode.footer.saveAsButton.boundingBox())?.width
|
||||||
|
)
|
||||||
|
.toBe(disabledWidth)
|
||||||
|
|
||||||
// Save the workflow to transition to the Save + chevron state
|
// Save the workflow to transition to the Save + chevron state
|
||||||
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
|
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
|
||||||
await appMode.saveAs.closeButton.click()
|
await dismissSuccessDialog(appMode.saveAs)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
// State 3: Save + chevron button group (saved workflow)
|
// State 3: Save + chevron button group (saved workflow)
|
||||||
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
|
await expect
|
||||||
expect(saveButtonGroupBox).toBeTruthy()
|
.poll(async () => (await appMode.footer.saveGroup.boundingBox())?.width)
|
||||||
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
|
.toBe(disabledWidth)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Connect output popover appears when no outputs selected', async ({
|
test('Connect output popover appears when no outputs selected', async ({
|
||||||
@@ -197,7 +209,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.page.getByText('Connect an output', { exact: false })
|
comfyPage.page.getByText('Connect an output', { exact: false })
|
||||||
).toBeVisible({ timeout: 5000 })
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as app produces correct extension and linearMode', async ({
|
test('save as app produces correct extension and linearMode', async ({
|
||||||
@@ -206,11 +218,13 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
|
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
|
||||||
|
|
||||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
await expect
|
||||||
expect(path).toContain('.app.json')
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
|
.toContain('.app.json')
|
||||||
|
|
||||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
await expect
|
||||||
expect(linearMode).toBe(true)
|
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as node graph produces correct extension and linearMode', async ({
|
test('save as node graph produces correct extension and linearMode', async ({
|
||||||
@@ -223,12 +237,15 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
'Node graph'
|
'Node graph'
|
||||||
)
|
)
|
||||||
|
|
||||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
await expect(async () => {
|
||||||
expect(path).toMatch(/\.json$/)
|
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||||
expect(path).not.toContain('.app.json')
|
expect(path).toMatch(/\.json$/)
|
||||||
|
expect(path).not.toContain('.app.json')
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
|
||||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
await expect
|
||||||
expect(linearMode).toBe(false)
|
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
|
||||||
|
.toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as app View App button enters app mode', async ({ comfyPage }) => {
|
test('save as app View App button enters app mode', async ({ comfyPage }) => {
|
||||||
@@ -236,11 +253,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
|
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
|
||||||
|
|
||||||
await comfyPage.appMode.saveAs.viewAppButton.click()
|
await comfyPage.appMode.saveAs.viewAppButton.click()
|
||||||
await comfyPage.nextFrame()
|
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
|
||||||
|
|
||||||
expect(await comfyPage.workflow.getActiveWorkflowActiveAppMode()).toBe(
|
await expect
|
||||||
'app'
|
.poll(() => comfyPage.workflow.getActiveWorkflowActiveAppMode())
|
||||||
)
|
.toBe('app')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as node graph Exit builder exits builder mode', async ({
|
test('save as node graph Exit builder exits builder mode', async ({
|
||||||
@@ -254,9 +271,9 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await comfyPage.appMode.saveAs.exitBuilderButton.click()
|
await comfyPage.appMode.saveAs.exitBuilderButton.click()
|
||||||
await comfyPage.nextFrame()
|
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
|
||||||
|
|
||||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as with different mode does not modify the original workflow', async ({
|
test('save as with different mode does not modify the original workflow', async ({
|
||||||
@@ -267,27 +284,27 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
|
|
||||||
const originalName = `${Date.now()} original`
|
const originalName = `${Date.now()} original`
|
||||||
await builderSaveAs(appMode, originalName, 'App')
|
await builderSaveAs(appMode, originalName, 'App')
|
||||||
const originalPath = await comfyPage.workflow.getActiveWorkflowPath()
|
await expect
|
||||||
expect(originalPath).toContain('.app.json')
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
await appMode.saveAs.closeButton.click()
|
.toContain('.app.json')
|
||||||
await comfyPage.nextFrame()
|
await dismissSuccessDialog(appMode.saveAs)
|
||||||
|
|
||||||
// Re-save as node graph — creates a copy
|
// Re-save as node graph — creates a copy
|
||||||
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
|
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
|
||||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
await expect(appMode.saveAs.successMessage).toBeVisible()
|
||||||
|
|
||||||
const newPath = await comfyPage.workflow.getActiveWorkflowPath()
|
await expect
|
||||||
expect(newPath).not.toBe(originalPath)
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
expect(newPath).not.toContain('.app.json')
|
.not.toContain('.app.json')
|
||||||
|
|
||||||
// Dismiss success dialog, exit app mode, reopen the original
|
// Dismiss success dialog, exit app mode, reopen the original
|
||||||
await appMode.saveAs.dismissButton.click()
|
await dismissSuccessDialog(appMode.saveAs, 'dismiss')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await appMode.toggleAppMode()
|
await appMode.toggleAppMode()
|
||||||
await openWorkflowFromSidebar(comfyPage, originalName)
|
await openWorkflowFromSidebar(comfyPage, originalName)
|
||||||
|
|
||||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
await expect
|
||||||
expect(linearMode).toBe(true)
|
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as with same name and same mode overwrites in place', async ({
|
test('save as with same name and same mode overwrites in place', async ({
|
||||||
@@ -298,20 +315,25 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
|
|
||||||
await builderSaveAs(appMode, name, 'App')
|
await builderSaveAs(appMode, name, 'App')
|
||||||
await appMode.saveAs.closeButton.click()
|
await dismissSuccessDialog(appMode.saveAs)
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
|
.toContain('.app.json')
|
||||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||||
|
|
||||||
await reSaveAs(appMode, name, 'App')
|
await reSaveAs(appMode, name, 'App')
|
||||||
|
|
||||||
await expect(appMode.saveAs.overwriteDialog).toBeVisible({ timeout: 5000 })
|
await expect(appMode.saveAs.overwriteDialog).toBeVisible()
|
||||||
await appMode.saveAs.overwriteButton.click()
|
await appMode.saveAs.overwriteButton.click()
|
||||||
|
await expect(appMode.saveAs.overwriteDialog).toBeHidden()
|
||||||
|
|
||||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
await expect(appMode.saveAs.successMessage).toBeVisible()
|
||||||
|
|
||||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
await expect
|
||||||
expect(pathAfterSecond).toBe(pathAfterFirst)
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
|
.toBe(pathAfterFirst)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as with same name but different mode creates a new file', async ({
|
test('save as with same name but different mode creates a new file', async ({
|
||||||
@@ -322,32 +344,38 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
|
|
||||||
await builderSaveAs(appMode, name, 'App')
|
await builderSaveAs(appMode, name, 'App')
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
|
.toContain('.app.json')
|
||||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||||
expect(pathAfterFirst).toContain('.app.json')
|
await dismissSuccessDialog(appMode.saveAs)
|
||||||
await appMode.saveAs.closeButton.click()
|
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
await reSaveAs(appMode, name, 'Node graph')
|
await reSaveAs(appMode, name, 'Node graph')
|
||||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
await expect(appMode.saveAs.successMessage).toBeVisible()
|
||||||
|
|
||||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
await expect
|
||||||
expect(pathAfterSecond).not.toBe(pathAfterFirst)
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
expect(pathAfterSecond).toMatch(/\.json$/)
|
.not.toBe(pathAfterFirst)
|
||||||
expect(pathAfterSecond).not.toContain('.app.json')
|
await expect
|
||||||
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
|
.toMatch(/\.json$/)
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||||
|
.not.toContain('.app.json')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as app workflow reloads in app mode', async ({ comfyPage }) => {
|
test('save as app workflow reloads in app mode', async ({ comfyPage }) => {
|
||||||
const name = `${Date.now()} reload-app`
|
const name = `${Date.now()} reload-app`
|
||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
await builderSaveAs(comfyPage.appMode, name, 'App')
|
await builderSaveAs(comfyPage.appMode, name, 'App')
|
||||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
await dismissSuccessDialog(comfyPage.appMode.saveAs, 'dismiss')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await comfyPage.appMode.footer.exitBuilder()
|
await comfyPage.appMode.footer.exitBuilder()
|
||||||
|
|
||||||
await openWorkflowFromSidebar(comfyPage, name)
|
await openWorkflowFromSidebar(comfyPage, name)
|
||||||
|
|
||||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
await expect
|
||||||
expect(mode).toBe('app')
|
.poll(() => comfyPage.workflow.getActiveWorkflowInitialMode())
|
||||||
|
.toBe('app')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save as node graph workflow reloads in node graph mode', async ({
|
test('save as node graph workflow reloads in node graph mode', async ({
|
||||||
@@ -356,13 +384,13 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
|||||||
const name = `${Date.now()} reload-graph`
|
const name = `${Date.now()} reload-graph`
|
||||||
await setupBuilder(comfyPage)
|
await setupBuilder(comfyPage)
|
||||||
await builderSaveAs(comfyPage.appMode, name, 'Node graph')
|
await builderSaveAs(comfyPage.appMode, name, 'Node graph')
|
||||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
await dismissSuccessDialog(comfyPage.appMode.saveAs, 'dismiss')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await comfyPage.appMode.toggleAppMode()
|
await comfyPage.appMode.toggleAppMode()
|
||||||
|
|
||||||
await openWorkflowFromSidebar(comfyPage, name)
|
await openWorkflowFromSidebar(comfyPage, name)
|
||||||
|
|
||||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
await expect
|
||||||
expect(mode).toBe('graph')
|
.poll(() => comfyPage.workflow.getActiveWorkflowInitialMode())
|
||||||
|
.toBe('graph')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
275
browser_tests/tests/canvasModeSelector.spec.ts
Normal file
275
browser_tests/tests/canvasModeSelector.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
|
const getLocators = (page: Page) => ({
|
||||||
|
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
|
||||||
|
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
|
||||||
|
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
|
||||||
|
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const MODES = [
|
||||||
|
{
|
||||||
|
label: 'Select',
|
||||||
|
activateCommand: 'Comfy.Canvas.Unlock',
|
||||||
|
isReadOnly: false,
|
||||||
|
iconPattern: /lucide--mouse-pointer-2/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hand',
|
||||||
|
activateCommand: 'Comfy.Canvas.Lock',
|
||||||
|
isReadOnly: true,
|
||||||
|
iconPattern: /lucide--hand/
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||||
|
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Trigger button', () => {
|
||||||
|
test('visible in canvas toolbar with ARIA markup', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const { trigger } = getLocators(comfyPage.page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const mode of MODES) {
|
||||||
|
test(`shows ${mode.label}-mode icon on trigger button`, async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
const { trigger } = getLocators(comfyPage.page)
|
||||||
|
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||||
|
await expect(modeIcon).toHaveClass(mode.iconPattern)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Popover lifecycle', () => {
|
||||||
|
test('opens when trigger is clicked', async ({ comfyPage }) => {
|
||||||
|
const { trigger, menu } = getLocators(comfyPage.page)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closes when trigger is clicked again', async ({ comfyPage }) => {
|
||||||
|
const { trigger, menu } = getLocators(comfyPage.page)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeHidden()
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closes after a mode item is selected', async ({ comfyPage }) => {
|
||||||
|
const { trigger, menu, handItem } = getLocators(comfyPage.page)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await handItem.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closes when Escape is pressed', async ({ comfyPage }) => {
|
||||||
|
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await selectItem.press('Escape')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeHidden()
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Mode switching', () => {
|
||||||
|
for (const mode of MODES) {
|
||||||
|
test(`clicking "${mode.label}" sets canvas readOnly=${mode.isReadOnly}`, async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
if (!mode.isReadOnly) {
|
||||||
|
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
}
|
||||||
|
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||||
|
comfyPage.page
|
||||||
|
)
|
||||||
|
const item = mode.isReadOnly ? handItem : selectItem
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.canvasOps.isReadOnly())
|
||||||
|
.toBe(mode.isReadOnly)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('clicking the currently active item is a no-op', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
expect(
|
||||||
|
await comfyPage.canvasOps.isReadOnly(),
|
||||||
|
'Precondition: canvas starts in Select mode'
|
||||||
|
).toBe(false)
|
||||||
|
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await selectItem.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('ARIA state', () => {
|
||||||
|
test('aria-checked marks Select active on default load', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||||
|
comfyPage.page
|
||||||
|
)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await expect(selectItem).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await expect(handItem).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const mode of MODES) {
|
||||||
|
test(`tabindex=0 is on the active "${mode.label}" item`, async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||||
|
comfyPage.page
|
||||||
|
)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const activeItem = mode.isReadOnly ? handItem : selectItem
|
||||||
|
const inactiveItem = mode.isReadOnly ? selectItem : handItem
|
||||||
|
|
||||||
|
await expect(activeItem).toHaveAttribute('tabindex', '0')
|
||||||
|
await expect(inactiveItem).toHaveAttribute('tabindex', '-1')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Keyboard navigation', () => {
|
||||||
|
test('ArrowDown moves focus from Select to Hand', async ({ comfyPage }) => {
|
||||||
|
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||||
|
comfyPage.page
|
||||||
|
)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await selectItem.press('ArrowDown')
|
||||||
|
await expect(handItem).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Escape closes popover and restores focus to trigger', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||||
|
comfyPage.page
|
||||||
|
)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await selectItem.press('ArrowDown')
|
||||||
|
await handItem.press('Escape')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeHidden()
|
||||||
|
await expect(trigger).toBeFocused()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Focus management on open', () => {
|
||||||
|
for (const mode of MODES) {
|
||||||
|
test(`auto-focuses the checked "${mode.label}" item on open`, async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||||
|
comfyPage.page
|
||||||
|
)
|
||||||
|
const item = mode.isReadOnly ? handItem : selectItem
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await expect(item).toBeFocused()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Keybinding integration', { tag: '@keyboard' }, () => {
|
||||||
|
test("'H' locks canvas and updates trigger icon to Hand", async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
expect(
|
||||||
|
await comfyPage.canvasOps.isReadOnly(),
|
||||||
|
'Precondition: canvas starts unlocked'
|
||||||
|
).toBe(false)
|
||||||
|
await comfyPage.canvas.press('KeyH')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||||
|
const { trigger } = getLocators(comfyPage.page)
|
||||||
|
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||||
|
await expect(modeIcon).toHaveClass(/lucide--hand/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("'V' unlocks canvas and updates trigger icon to Select", async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
expect(
|
||||||
|
await comfyPage.canvasOps.isReadOnly(),
|
||||||
|
'Precondition: canvas starts locked'
|
||||||
|
).toBe(true)
|
||||||
|
await comfyPage.canvas.press('KeyV')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||||
|
const { trigger } = getLocators(comfyPage.page)
|
||||||
|
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||||
|
await expect(modeIcon).toHaveClass(/lucide--mouse-pointer-2/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Shortcut hint display', () => {
|
||||||
|
test('menu items show non-empty keyboard shortcut text', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||||
|
comfyPage.page
|
||||||
|
)
|
||||||
|
await trigger.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
const selectHint = selectItem.getByTestId('shortcut-hint')
|
||||||
|
const handHint = handItem.getByTestId('shortcut-hint')
|
||||||
|
|
||||||
|
await expect(selectHint).not.toBeEmpty()
|
||||||
|
await expect(handHint).not.toBeEmpty()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,14 +1,94 @@
|
|||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
|
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||||
import {
|
import {
|
||||||
comfyExpect as expect,
|
comfyExpect as expect,
|
||||||
comfyPageFixture as test
|
comfyPageFixture as test
|
||||||
} from '@e2e/fixtures/ComfyPage'
|
} from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
|
type ChangeTrackerDebugState = {
|
||||||
|
changeCount: number
|
||||||
|
graphMatchesActiveState: boolean
|
||||||
|
isLoadingGraph: boolean
|
||||||
|
isModified: boolean | undefined
|
||||||
|
redoQueueSize: number
|
||||||
|
restoringState: boolean
|
||||||
|
undoQueueSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChangeTrackerDebugState(comfyPage: ComfyPage) {
|
||||||
|
return await comfyPage.page.evaluate(() => {
|
||||||
|
type ChangeTrackerClassLike = {
|
||||||
|
graphEqual: (left: unknown, right: unknown) => boolean
|
||||||
|
isLoadingGraph: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeTrackerLike = {
|
||||||
|
_restoringState: boolean
|
||||||
|
activeState: unknown
|
||||||
|
changeCount: number
|
||||||
|
constructor: ChangeTrackerClassLike
|
||||||
|
redoQueue: unknown[]
|
||||||
|
undoQueue: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveWorkflowLike = {
|
||||||
|
changeTracker?: ChangeTrackerLike
|
||||||
|
isModified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowStore = window.app!.extensionManager as WorkspaceStore
|
||||||
|
const workflow = workflowStore.workflow
|
||||||
|
.activeWorkflow as ActiveWorkflowLike | null
|
||||||
|
const tracker = workflow?.changeTracker
|
||||||
|
if (!workflow || !tracker) {
|
||||||
|
throw new Error('Active workflow change tracker is not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentState = JSON.parse(
|
||||||
|
JSON.stringify(window.app!.rootGraph.serialize())
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
changeCount: tracker.changeCount,
|
||||||
|
graphMatchesActiveState: tracker.constructor.graphEqual(
|
||||||
|
tracker.activeState,
|
||||||
|
currentState
|
||||||
|
),
|
||||||
|
isLoadingGraph: tracker.constructor.isLoadingGraph,
|
||||||
|
isModified: workflow.isModified,
|
||||||
|
redoQueueSize: tracker.redoQueue.length,
|
||||||
|
restoringState: tracker._restoringState,
|
||||||
|
undoQueueSize: tracker.undoQueue.length
|
||||||
|
} satisfies ChangeTrackerDebugState
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForChangeTrackerSettled(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
expected: Pick<
|
||||||
|
ChangeTrackerDebugState,
|
||||||
|
'isModified' | 'redoQueueSize' | 'undoQueueSize'
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
// Visible node flags can flip before undo finishes loadGraphData() and
|
||||||
|
// updates the tracker. Poll the tracker's own settled state so we do not
|
||||||
|
// start the next transaction while checkState() is still gated.
|
||||||
|
await expect
|
||||||
|
.poll(() => getChangeTrackerDebugState(comfyPage))
|
||||||
|
.toMatchObject({
|
||||||
|
changeCount: 0,
|
||||||
|
graphMatchesActiveState: true,
|
||||||
|
isLoadingGraph: false,
|
||||||
|
restoringState: false,
|
||||||
|
...expected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function beforeChange(comfyPage: ComfyPage) {
|
async function beforeChange(comfyPage: ComfyPage) {
|
||||||
await comfyPage.page.evaluate(() => {
|
await comfyPage.page.evaluate(() => {
|
||||||
window.app!.canvas!.emitBeforeChange()
|
window.app!.canvas!.emitBeforeChange()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function afterChange(comfyPage: ComfyPage) {
|
async function afterChange(comfyPage: ComfyPage) {
|
||||||
await comfyPage.page.evaluate(() => {
|
await comfyPage.page.evaluate(() => {
|
||||||
window.app!.canvas!.emitAfterChange()
|
window.app!.canvas!.emitAfterChange()
|
||||||
@@ -32,7 +112,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
|||||||
|
|
||||||
// Save, confirm no errors & workflow modified flag removed
|
// Save, confirm no errors & workflow modified flag removed
|
||||||
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
|
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
|
||||||
await expect.poll(() => comfyPage.toast.getToastErrorCount()).toBe(0)
|
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
|
||||||
await expect
|
await expect
|
||||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||||
.toBe(false)
|
.toBe(false)
|
||||||
@@ -59,19 +139,19 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
|||||||
|
|
||||||
await comfyPage.keyboard.undo()
|
await comfyPage.keyboard.undo()
|
||||||
await expect(node).not.toBeBypassed()
|
await expect(node).not.toBeBypassed()
|
||||||
await expect
|
await waitForChangeTrackerSettled(comfyPage, {
|
||||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
isModified: true,
|
||||||
.toBe(true)
|
redoQueueSize: 1,
|
||||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
undoQueueSize: 1
|
||||||
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(1)
|
})
|
||||||
|
|
||||||
await comfyPage.keyboard.undo()
|
await comfyPage.keyboard.undo()
|
||||||
await expect(node).not.toBeCollapsed()
|
await expect(node).not.toBeCollapsed()
|
||||||
await expect
|
await waitForChangeTrackerSettled(comfyPage, {
|
||||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
isModified: false,
|
||||||
.toBe(false)
|
redoQueueSize: 2,
|
||||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
undoQueueSize: 0
|
||||||
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(2)
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -98,6 +178,11 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
|||||||
await comfyPage.keyboard.undo()
|
await comfyPage.keyboard.undo()
|
||||||
await expect(node).not.toBeBypassed()
|
await expect(node).not.toBeBypassed()
|
||||||
await expect(node).not.toBeCollapsed()
|
await expect(node).not.toBeCollapsed()
|
||||||
|
await waitForChangeTrackerSettled(comfyPage, {
|
||||||
|
isModified: false,
|
||||||
|
redoQueueSize: 2,
|
||||||
|
undoQueueSize: 0
|
||||||
|
})
|
||||||
|
|
||||||
// Prevent clicks registering a double-click
|
// Prevent clicks registering a double-click
|
||||||
await comfyPage.canvasOps.clickEmptySpace()
|
await comfyPage.canvasOps.clickEmptySpace()
|
||||||
@@ -113,11 +198,21 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
|||||||
|
|
||||||
// End transaction
|
// End transaction
|
||||||
await afterChange(comfyPage)
|
await afterChange(comfyPage)
|
||||||
|
await waitForChangeTrackerSettled(comfyPage, {
|
||||||
|
isModified: true,
|
||||||
|
redoQueueSize: 0,
|
||||||
|
undoQueueSize: 1
|
||||||
|
})
|
||||||
|
|
||||||
// Ensure undo reverts both changes
|
// Ensure undo reverts both changes
|
||||||
await comfyPage.keyboard.undo()
|
await comfyPage.keyboard.undo()
|
||||||
await expect(node).not.toBeBypassed()
|
await expect(node).not.toBeBypassed()
|
||||||
await expect(node).not.toBeCollapsed()
|
await expect(node).not.toBeCollapsed()
|
||||||
|
await waitForChangeTrackerSettled(comfyPage, {
|
||||||
|
isModified: false,
|
||||||
|
redoQueueSize: 1,
|
||||||
|
undoQueueSize: 0
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can nest multiple change transactions without adding undo steps', async ({
|
test('Can nest multiple change transactions without adding undo steps', async ({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test.describe(
|
|||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Tab 0: default workflow (7 nodes)
|
// Tab 0: default workflow (7 nodes)
|
||||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||||
|
|
||||||
// Save tab 0 so it has a unique name for tab switching
|
// Save tab 0 so it has a unique name for tab switching
|
||||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||||
@@ -42,25 +42,21 @@ test.describe(
|
|||||||
|
|
||||||
// Create tab 1: blank workflow (0 nodes)
|
// Create tab 1: blank workflow (0 nodes)
|
||||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||||
await comfyPage.nextFrame()
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
|
||||||
|
|
||||||
// Switch back to tab 0 (workflow-a).
|
// Switch back to tab 0 (workflow-a).
|
||||||
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
|
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
|
||||||
await tab0.click()
|
await tab0.click()
|
||||||
await comfyPage.nextFrame()
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
|
||||||
|
|
||||||
// switch to blank tab and back to verify no corruption
|
// switch to blank tab and back to verify no corruption
|
||||||
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
|
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
|
||||||
await tab1.click()
|
await tab1.click()
|
||||||
await comfyPage.nextFrame()
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
|
||||||
|
|
||||||
// switch again and verify no corruption
|
// switch again and verify no corruption
|
||||||
await tab0.click()
|
await tab0.click()
|
||||||
await comfyPage.nextFrame()
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,18 +65,28 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
|||||||
|
|
||||||
// Add a new CheckpointLoaderSimple — should use first cloud asset,
|
// Add a new CheckpointLoaderSimple — should use first cloud asset,
|
||||||
// not the server's object_info default.
|
// not the server's object_info default.
|
||||||
const widgetValue = await comfyPage.page.evaluate(() => {
|
|
||||||
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
|
|
||||||
window.app!.graph.add(node!)
|
|
||||||
const widget = node!.widgets?.find(
|
|
||||||
(w: { name: string }) => w.name === 'ckpt_name'
|
|
||||||
)
|
|
||||||
return String(widget?.value ?? '')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Production resolves via getAssetFilename (user_metadata.filename →
|
// Production resolves via getAssetFilename (user_metadata.filename →
|
||||||
// metadata.filename → asset.name). Test fixtures have no metadata
|
// metadata.filename → asset.name). Test fixtures have no metadata
|
||||||
// filename, so asset.name is the resolved value.
|
// filename, so asset.name is the resolved value.
|
||||||
expect(widgetValue).toBe(CLOUD_ASSETS[0].name)
|
const nodeId = await comfyPage.page.evaluate(() => {
|
||||||
|
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
|
||||||
|
window.app!.graph.add(node!)
|
||||||
|
return node!.id
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
return await comfyPage.page.evaluate((id) => {
|
||||||
|
const node = window.app!.graph.getNodeById(id)
|
||||||
|
const widget = node?.widgets?.find(
|
||||||
|
(w: { name: string }) => w.name === 'ckpt_name'
|
||||||
|
)
|
||||||
|
return String(widget?.value ?? '')
|
||||||
|
}, nodeId)
|
||||||
|
},
|
||||||
|
{ timeout: 10_000 }
|
||||||
|
)
|
||||||
|
.toBe(CLOUD_ASSETS[0].name)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ test.describe(
|
|||||||
await node.toggleCollapse()
|
await node.toggleCollapse()
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await expect.poll(async () => await node.boundingBox()).not.toBeNull()
|
||||||
const box = await node.boundingBox()
|
const box = await node.boundingBox()
|
||||||
expect(box).not.toBeNull()
|
|
||||||
await comfyPage.page.mouse.move(
|
await comfyPage.page.mouse.move(
|
||||||
box!.x + box!.width / 2,
|
box!.x + box!.width / 2,
|
||||||
box!.y + box!.height / 2
|
box!.y + box!.height / 2
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
|||||||
|
|
||||||
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
|
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
|
||||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
'custom-color-palette-obsidian-dark-all-colors.png'
|
'custom-color-palette-obsidian-dark-all-colors.png'
|
||||||
)
|
)
|
||||||
@@ -177,7 +178,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
|||||||
window.app!.extensionManager as WorkspaceStore
|
window.app!.extensionManager as WorkspaceStore
|
||||||
).colorPalette.addCustomColorPalette(p)
|
).colorPalette.addCustomColorPalette(p)
|
||||||
}, customColorPalettes.obsidian_dark)
|
}, customColorPalettes.obsidian_dark)
|
||||||
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
|
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
|
||||||
|
|
||||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
@@ -211,12 +212,14 @@ test.describe(
|
|||||||
|
|
||||||
// Drag mouse to force canvas to redraw
|
// Drag mouse to force canvas to redraw
|
||||||
await comfyPage.page.mouse.move(0, 0)
|
await comfyPage.page.mouse.move(0, 0)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
||||||
|
|
||||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
|
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
|
||||||
|
|
||||||
await comfyPage.page.mouse.move(8, 8)
|
await comfyPage.page.mouse.move(8, 8)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -225,8 +228,8 @@ test.describe(
|
|||||||
}) => {
|
}) => {
|
||||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
|
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
|
||||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
|
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await comfyPage.page.mouse.move(0, 0)
|
await comfyPage.page.mouse.move(0, 0)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
'node-opacity-0.2-arc-theme.png'
|
'node-opacity-0.2-arc-theme.png'
|
||||||
)
|
)
|
||||||
@@ -238,22 +241,38 @@ test.describe(
|
|||||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
||||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
const parsed = await comfyPage.page.evaluate(() => {
|
await expect
|
||||||
const graph = window.app!.graph!
|
.poll(() =>
|
||||||
if (typeof graph.serialize !== 'function') {
|
comfyPage.page.evaluate(() => {
|
||||||
throw new Error('app.graph.serialize is not available')
|
const graph = window.app!.graph!
|
||||||
}
|
if (typeof graph.serialize !== 'function') return undefined
|
||||||
return graph.serialize() as {
|
const parsed = graph.serialize() as {
|
||||||
nodes: Array<{ bgcolor?: string; color?: string }>
|
nodes: Array<{ bgcolor?: string; color?: string }>
|
||||||
}
|
}
|
||||||
})
|
return parsed.nodes
|
||||||
expect(parsed.nodes).toBeDefined()
|
})
|
||||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
)
|
||||||
const nodes = parsed.nodes
|
.toBeDefined()
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
await expect
|
||||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
.poll(async () => {
|
||||||
}
|
const nodes = await comfyPage.page.evaluate(() => {
|
||||||
|
return (
|
||||||
|
window.app!.graph!.serialize() as {
|
||||||
|
nodes: Array<{ bgcolor?: string; color?: string }>
|
||||||
|
}
|
||||||
|
).nodes
|
||||||
|
})
|
||||||
|
if (!Array.isArray(nodes)) return 'not an array'
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.bgcolor && /hsla/.test(node.bgcolor))
|
||||||
|
return `bgcolor contains hsla: ${node.bgcolor}`
|
||||||
|
if (node.color && /hsla/.test(node.color))
|
||||||
|
return `color contains hsla: ${node.color}`
|
||||||
|
}
|
||||||
|
return 'ok'
|
||||||
|
})
|
||||||
|
.toBe('ok')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should lighten node colors when switching to light theme', async ({
|
test('should lighten node colors when switching to light theme', async ({
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.command.executeCommand('TestCommand')
|
await comfyPage.command.executeCommand('TestCommand')
|
||||||
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.foo))
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should execute async command', async ({ comfyPage }) => {
|
test('Should execute async command', async ({ comfyPage }) => {
|
||||||
@@ -27,7 +29,9 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.command.executeCommand('TestCommand')
|
await comfyPage.command.executeCommand('TestCommand')
|
||||||
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.foo))
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should handle command errors', async ({ comfyPage }) => {
|
test('Should handle command errors', async ({ comfyPage }) => {
|
||||||
@@ -36,7 +40,7 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.command.executeCommand('TestCommand')
|
await comfyPage.command.executeCommand('TestCommand')
|
||||||
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
|
await expect(comfyPage.toast.toastErrors).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should handle async command errors', async ({ comfyPage }) => {
|
test('Should handle async command errors', async ({ comfyPage }) => {
|
||||||
@@ -49,6 +53,6 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.command.executeCommand('TestCommand')
|
await comfyPage.command.executeCommand('TestCommand')
|
||||||
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
|
await expect(comfyPage.toast.toastErrors).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -149,11 +149,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
|||||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
await comfyPage.clipboard.paste()
|
await comfyPage.clipboard.paste()
|
||||||
await expect
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
|
||||||
timeout: 5_000
|
|
||||||
})
|
|
||||||
.toBe(3)
|
|
||||||
|
|
||||||
// Step 2: Paste image onto selected LoadImage node
|
// Step 2: Paste image onto selected LoadImage node
|
||||||
const loadImageNodes =
|
const loadImageNodes =
|
||||||
@@ -171,13 +167,10 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
|||||||
await uploadPromise
|
await uploadPromise
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(async () => {
|
||||||
async () => {
|
const fileWidget = await loadImageNodes[0].getWidget(0)
|
||||||
const fileWidget = await loadImageNodes[0].getWidget(0)
|
return fileWidget.getValue()
|
||||||
return fileWidget.getValue()
|
})
|
||||||
},
|
|
||||||
{ timeout: 5_000 }
|
|
||||||
)
|
|
||||||
.toContain('image32x32')
|
.toContain('image32x32')
|
||||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||||
|
|
||||||
@@ -194,11 +187,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
|||||||
)
|
)
|
||||||
await uploadPromise2
|
await uploadPromise2
|
||||||
|
|
||||||
await expect
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(4)
|
||||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
|
||||||
timeout: 5_000
|
|
||||||
})
|
|
||||||
.toBe(4)
|
|
||||||
const allLoadImageNodes =
|
const allLoadImageNodes =
|
||||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||||
expect(allLoadImageNodes).toHaveLength(2)
|
expect(allLoadImageNodes).toHaveLength(2)
|
||||||
|
|||||||
@@ -4,22 +4,19 @@ import type { Locator } from '@playwright/test'
|
|||||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
async function verifyCustomIconSvg(iconElement: Locator) {
|
async function verifyCustomIconSvg(iconElement: Locator) {
|
||||||
const svgVariable = await iconElement.evaluate((element) => {
|
await expect
|
||||||
const styles = getComputedStyle(element)
|
.poll(async () => {
|
||||||
return styles.getPropertyValue('--svg')
|
const svgVariable = await iconElement.evaluate((element) =>
|
||||||
})
|
getComputedStyle(element).getPropertyValue('--svg')
|
||||||
|
)
|
||||||
expect(svgVariable).toBeTruthy()
|
if (!svgVariable) return null
|
||||||
const dataUrlMatch = svgVariable.match(
|
const dataUrlMatch = svgVariable.match(
|
||||||
/url\("data:image\/svg\+xml,([^"]+)"\)/
|
/url\("data:image\/svg\+xml,([^"]+)"\)/
|
||||||
)
|
)
|
||||||
expect(dataUrlMatch).toBeTruthy()
|
if (!dataUrlMatch) return null
|
||||||
|
return decodeURIComponent(dataUrlMatch[1])
|
||||||
const encodedSvg = dataUrlMatch![1]
|
})
|
||||||
const decodedSvg = decodeURIComponent(encodedSvg)
|
.toContain("<svg xmlns='http://www.w3.org/2000/svg'")
|
||||||
|
|
||||||
// Check for SVG header to confirm it's a valid SVG
|
|
||||||
expect(decodedSvg).toContain("<svg xmlns='http://www.w3.org/2000/svg'")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Custom Icons', { tag: '@settings' }, () => {
|
test.describe('Custom Icons', { tag: '@settings' }, () => {
|
||||||
|
|||||||
@@ -38,15 +38,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
`.${tabId}-tab-button.side-bar-button-selected`
|
`.${tabId}-tab-button.side-bar-button-selected`
|
||||||
)
|
)
|
||||||
|
|
||||||
await expect(selectedButton).not.toBeVisible()
|
await expect(selectedButton).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.canvas.press(key)
|
await comfyPage.canvas.press(key)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await expect(selectedButton).toBeVisible()
|
await expect(selectedButton).toBeVisible()
|
||||||
|
|
||||||
await comfyPage.canvas.press(key)
|
await comfyPage.canvas.press(key)
|
||||||
await comfyPage.nextFrame()
|
await expect(selectedButton).toBeHidden()
|
||||||
await expect(selectedButton).not.toBeVisible()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -58,8 +56,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
await comfyPage.canvas.press('Alt+Equal')
|
await comfyPage.canvas.press('Alt+Equal')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
const newScale = await comfyPage.canvasOps.getScale()
|
await expect
|
||||||
expect(newScale).toBeGreaterThan(initialScale)
|
.poll(() => comfyPage.canvasOps.getScale())
|
||||||
|
.toBeGreaterThan(initialScale)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
||||||
@@ -68,15 +67,17 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
await comfyPage.canvas.press('Alt+Minus')
|
await comfyPage.canvas.press('Alt+Minus')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
const newScale = await comfyPage.canvasOps.getScale()
|
await expect
|
||||||
expect(newScale).toBeLessThan(initialScale)
|
.poll(() => comfyPage.canvasOps.getScale())
|
||||||
|
.toBeLessThan(initialScale)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("'.' fits view to nodes", async ({ comfyPage }) => {
|
test("'.' fits view to nodes", async ({ comfyPage }) => {
|
||||||
// Set scale very small so fit-view will zoom back to fit nodes
|
// Set scale very small so fit-view will zoom back to fit nodes
|
||||||
await comfyPage.canvasOps.setScale(0.1)
|
await comfyPage.canvasOps.setScale(0.1)
|
||||||
const scaleBefore = await comfyPage.canvasOps.getScale()
|
await expect
|
||||||
expect(scaleBefore).toBeCloseTo(0.1, 1)
|
.poll(() => comfyPage.canvasOps.getScale())
|
||||||
|
.toBeCloseTo(0.1, 1)
|
||||||
|
|
||||||
// Click canvas to ensure focus is within graph-canvas-container
|
// Click canvas to ensure focus is within graph-canvas-container
|
||||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||||
@@ -85,29 +86,30 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
await comfyPage.canvas.press('Period')
|
await comfyPage.canvas.press('Period')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
const scaleAfter = await comfyPage.canvasOps.getScale()
|
await expect
|
||||||
expect(scaleAfter).toBeGreaterThan(scaleBefore)
|
.poll(() => comfyPage.canvasOps.getScale())
|
||||||
|
.toBeGreaterThan(0.1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("'h' locks canvas", async ({ comfyPage }) => {
|
test("'h' locks canvas", async ({ comfyPage }) => {
|
||||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||||
|
|
||||||
await comfyPage.canvas.press('KeyH')
|
await comfyPage.canvas.press('KeyH')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
||||||
// Lock first
|
// Lock first
|
||||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||||
|
|
||||||
await comfyPage.canvas.press('KeyV')
|
await comfyPage.canvas.press('KeyV')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,15 +124,15 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
await node.click('title')
|
await node.click('title')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
expect(await node.isCollapsed()).toBe(false)
|
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||||
|
|
||||||
await comfyPage.canvas.press('Alt+KeyC')
|
await comfyPage.canvas.press('Alt+KeyC')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
expect(await node.isCollapsed()).toBe(true)
|
await expect.poll(() => node.isCollapsed()).toBe(true)
|
||||||
|
|
||||||
await comfyPage.canvas.press('Alt+KeyC')
|
await comfyPage.canvas.press('Alt+KeyC')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
expect(await node.isCollapsed()).toBe(false)
|
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
|
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
|
||||||
@@ -147,16 +149,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
|
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
|
||||||
}, node.id)
|
}, node.id)
|
||||||
|
|
||||||
expect(await getMode()).toBe(0)
|
await expect.poll(() => getMode()).toBe(0)
|
||||||
|
|
||||||
await comfyPage.canvas.press('Control+KeyM')
|
await comfyPage.canvas.press('Control+KeyM')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
// NEVER (2) = muted
|
// NEVER (2) = muted
|
||||||
expect(await getMode()).toBe(2)
|
await expect.poll(() => getMode()).toBe(2)
|
||||||
|
|
||||||
await comfyPage.canvas.press('Control+KeyM')
|
await comfyPage.canvas.press('Control+KeyM')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
expect(await getMode()).toBe(0)
|
await expect.poll(() => getMode()).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,12 +172,10 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
|
|
||||||
// Toggle off with Alt+m
|
// Toggle off with Alt+m
|
||||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||||
await comfyPage.nextFrame()
|
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||||
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
|
|
||||||
|
|
||||||
// Toggle on again
|
// Toggle on again
|
||||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -189,26 +189,22 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
await expect(minimap).toBeVisible()
|
await expect(minimap).toBeVisible()
|
||||||
|
|
||||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||||
await comfyPage.nextFrame()
|
await expect(minimap).toBeHidden()
|
||||||
await expect(minimap).not.toBeVisible()
|
|
||||||
|
|
||||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await expect(minimap).toBeVisible()
|
await expect(minimap).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
|
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
|
||||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
|
||||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||||
|
|
||||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||||
await comfyPage.nextFrame()
|
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,7 +250,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
|
|
||||||
// The Save As dialog should appear (p-dialog overlay)
|
// The Save As dialog should appear (p-dialog overlay)
|
||||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||||
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
|
await expect(dialogOverlay).toBeVisible()
|
||||||
|
|
||||||
// Dismiss the dialog
|
// Dismiss the dialog
|
||||||
await comfyPage.page.keyboard.press('Escape')
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
@@ -278,7 +274,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
await comfyPage.page.keyboard.press('Control+o')
|
await comfyPage.page.keyboard.press('Control+o')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -286,8 +284,14 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
|
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => comfyPage.nodeOps.getGraphNodesCount(),
|
||||||
|
'Default workflow should have multiple nodes'
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(1)
|
||||||
|
|
||||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||||
expect(initialCount).toBeGreaterThan(1)
|
|
||||||
|
|
||||||
// Select all nodes
|
// Select all nodes
|
||||||
await comfyPage.canvas.press('Control+a')
|
await comfyPage.canvas.press('Control+a')
|
||||||
@@ -299,9 +303,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
|||||||
// After conversion, node count should decrease
|
// After conversion, node count should decrease
|
||||||
// (multiple nodes replaced by single subgraph node)
|
// (multiple nodes replaced by single subgraph node)
|
||||||
await expect
|
await expect
|
||||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
.toBeLessThan(initialCount)
|
.toBeLessThan(initialCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,55 +11,49 @@ test.beforeEach(async ({ comfyPage }) => {
|
|||||||
test.describe('Settings', () => {
|
test.describe('Settings', () => {
|
||||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await comfyPage.page.keyboard.press('Control+,')
|
||||||
const settingsDialog = comfyPage.page.locator(
|
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
|
||||||
'[data-testid="settings-dialog"]'
|
|
||||||
)
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
await expect(settingsDialog).toBeVisible()
|
||||||
const contentArea = settingsDialog.locator('main')
|
const contentArea = settingsDialog.locator('main')
|
||||||
await expect(contentArea).toBeVisible()
|
await expect(contentArea).toBeVisible()
|
||||||
const isUsableHeight = await contentArea.evaluate(
|
await expect
|
||||||
(el) => el.clientHeight > 30
|
.poll(() => contentArea.evaluate((el) => el.clientHeight))
|
||||||
)
|
.toBeGreaterThan(30)
|
||||||
expect(isUsableHeight).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can open settings with hotkey', async ({ comfyPage }) => {
|
test('Can open settings with hotkey', async ({ comfyPage }) => {
|
||||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||||
await comfyPage.page.keyboard.press(',')
|
await comfyPage.page.keyboard.press(',')
|
||||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||||
const settingsLocator = comfyPage.page.locator(
|
const settingsLocator = comfyPage.page.getByTestId('settings-dialog')
|
||||||
'[data-testid="settings-dialog"]'
|
|
||||||
)
|
|
||||||
await expect(settingsLocator).toBeVisible()
|
await expect(settingsLocator).toBeVisible()
|
||||||
await comfyPage.page.keyboard.press('Escape')
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
await expect(settingsLocator).not.toBeVisible()
|
await expect(settingsLocator).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
|
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
|
||||||
const maxSpeed = 2.5
|
const maxSpeed = 2.5
|
||||||
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
|
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
|
||||||
|
|
||||||
await test.step('Setting should persist', async () => {
|
await test.step('Setting should persist', async () => {
|
||||||
expect(await comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
|
await expect
|
||||||
maxSpeed
|
.poll(() => comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed'))
|
||||||
)
|
.toBe(maxSpeed)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||||
// Open the settings dialog
|
// Open the settings dialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await comfyPage.page.keyboard.press('Control+,')
|
||||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
|
|
||||||
|
|
||||||
// Open the keybinding tab
|
// Open the keybinding tab
|
||||||
const settingsDialog = comfyPage.page.locator(
|
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
|
||||||
'[data-testid="settings-dialog"]'
|
await expect(settingsDialog).toBeVisible()
|
||||||
)
|
|
||||||
await settingsDialog
|
await settingsDialog
|
||||||
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||||
.click()
|
.click()
|
||||||
await comfyPage.page.waitForSelector(
|
await expect(
|
||||||
'[placeholder="Search Keybindings..."]'
|
comfyPage.page.getByPlaceholder('Search Keybindings...')
|
||||||
)
|
).toBeVisible()
|
||||||
|
|
||||||
// Focus the 'New Blank Workflow' row
|
// Focus the 'New Blank Workflow' row
|
||||||
const newBlankWorkflowRow = comfyPage.page.locator('tr', {
|
const newBlankWorkflowRow = comfyPage.page.locator('tr', {
|
||||||
@@ -156,6 +150,6 @@ test.describe('Signin dialog', () => {
|
|||||||
await input.press('Control+v')
|
await input.press('Control+v')
|
||||||
await expect(input).toHaveValue('test_password')
|
await expect(input).toHaveValue('test_password')
|
||||||
|
|
||||||
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
417
browser_tests/tests/dialogs/managerDialog.spec.ts
Normal file
417
browser_tests/tests/dialogs/managerDialog.spec.ts
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
|
||||||
|
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||||
|
import type { components as RegistryComponents } from '@comfyorg/registry-types'
|
||||||
|
|
||||||
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||||
|
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||||
|
|
||||||
|
type InstalledPacksResponse =
|
||||||
|
ManagerComponents['schemas']['InstalledPacksResponse']
|
||||||
|
type RegistryNodePack = RegistryComponents['schemas']['Node']
|
||||||
|
|
||||||
|
interface AlgoliaSearchResult {
|
||||||
|
hits: Partial<AlgoliaNodePack>[]
|
||||||
|
nbHits: number
|
||||||
|
page: number
|
||||||
|
nbPages: number
|
||||||
|
hitsPerPage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlgoliaSearchResponse {
|
||||||
|
results: AlgoliaSearchResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_PACK_A: RegistryNodePack = {
|
||||||
|
id: 'test-pack-a',
|
||||||
|
name: 'Test Pack A',
|
||||||
|
description: 'A test custom node pack',
|
||||||
|
downloads: 5000,
|
||||||
|
status: 'NodeStatusActive',
|
||||||
|
publisher: { id: 'test-publisher', name: 'Test Publisher' },
|
||||||
|
latest_version: { version: '1.0.0', status: 'NodeVersionStatusActive' },
|
||||||
|
repository: 'https://github.com/test/pack-a',
|
||||||
|
tags: ['image', 'processing']
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_PACK_B: RegistryNodePack = {
|
||||||
|
id: 'test-pack-b',
|
||||||
|
name: 'Test Pack B',
|
||||||
|
description: 'Another test custom node pack for testing search',
|
||||||
|
downloads: 3000,
|
||||||
|
status: 'NodeStatusActive',
|
||||||
|
publisher: { id: 'another-publisher', name: 'Another Publisher' },
|
||||||
|
latest_version: { version: '2.1.0', status: 'NodeVersionStatusActive' },
|
||||||
|
repository: 'https://github.com/test/pack-b',
|
||||||
|
tags: ['video', 'generation']
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_PACK_C: RegistryNodePack = {
|
||||||
|
id: 'test-pack-c',
|
||||||
|
name: 'Test Pack C',
|
||||||
|
description: 'Third test pack',
|
||||||
|
downloads: 100,
|
||||||
|
status: 'NodeStatusActive',
|
||||||
|
publisher: { id: 'test-publisher', name: 'Test Publisher' },
|
||||||
|
latest_version: { version: '0.5.0', status: 'NodeVersionStatusActive' },
|
||||||
|
repository: 'https://github.com/test/pack-c'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_INSTALLED_PACKS: InstalledPacksResponse = {
|
||||||
|
'test-pack-a': {
|
||||||
|
ver: '1.0.0',
|
||||||
|
cnr_id: 'test-pack-a',
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
'test-pack-c': {
|
||||||
|
ver: '0.5.0',
|
||||||
|
cnr_id: 'test-pack-c',
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_HIT_A: Partial<AlgoliaNodePack> = {
|
||||||
|
objectID: 'test-pack-a',
|
||||||
|
id: 'test-pack-a',
|
||||||
|
name: 'Test Pack A',
|
||||||
|
description: 'A test custom node pack',
|
||||||
|
total_install: 5000,
|
||||||
|
status: 'NodeStatusActive',
|
||||||
|
publisher_id: 'test-publisher',
|
||||||
|
latest_version: '1.0.0',
|
||||||
|
latest_version_status: 'NodeVersionStatusActive',
|
||||||
|
repository_url: 'https://github.com/test/pack-a',
|
||||||
|
comfy_nodes: ['TestNodeA1', 'TestNodeA2'],
|
||||||
|
create_time: '2024-01-01T00:00:00Z',
|
||||||
|
update_time: '2024-06-01T00:00:00Z',
|
||||||
|
license: 'MIT',
|
||||||
|
tags: ['image', 'processing']
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_HIT_B: Partial<AlgoliaNodePack> = {
|
||||||
|
objectID: 'test-pack-b',
|
||||||
|
id: 'test-pack-b',
|
||||||
|
name: 'Test Pack B',
|
||||||
|
description: 'Another test custom node pack for testing search',
|
||||||
|
total_install: 3000,
|
||||||
|
status: 'NodeStatusActive',
|
||||||
|
publisher_id: 'another-publisher',
|
||||||
|
latest_version: '2.1.0',
|
||||||
|
latest_version_status: 'NodeVersionStatusActive',
|
||||||
|
repository_url: 'https://github.com/test/pack-b',
|
||||||
|
comfy_nodes: ['TestNodeB1'],
|
||||||
|
create_time: '2024-02-01T00:00:00Z',
|
||||||
|
update_time: '2024-07-01T00:00:00Z',
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
tags: ['video', 'generation']
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_HIT_C: Partial<AlgoliaNodePack> = {
|
||||||
|
objectID: 'test-pack-c',
|
||||||
|
id: 'test-pack-c',
|
||||||
|
name: 'Test Pack C',
|
||||||
|
description: 'Third test pack',
|
||||||
|
total_install: 100,
|
||||||
|
status: 'NodeStatusActive',
|
||||||
|
publisher_id: 'test-publisher',
|
||||||
|
latest_version: '0.5.0',
|
||||||
|
latest_version_status: 'NodeVersionStatusActive',
|
||||||
|
repository_url: 'https://github.com/test/pack-c',
|
||||||
|
comfy_nodes: ['TestNodeC1'],
|
||||||
|
create_time: '2024-03-01T00:00:00Z',
|
||||||
|
update_time: '2024-05-01T00:00:00Z',
|
||||||
|
license: 'MIT'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_ALGOLIA_RESPONSE: AlgoliaSearchResponse = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
hits: [MOCK_HIT_A, MOCK_HIT_B, MOCK_HIT_C],
|
||||||
|
nbHits: 3,
|
||||||
|
page: 0,
|
||||||
|
nbPages: 1,
|
||||||
|
hitsPerPage: 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_ALGOLIA_PACK_B_ONLY: AlgoliaSearchResponse = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
hits: [MOCK_HIT_B],
|
||||||
|
nbHits: 1,
|
||||||
|
page: 0,
|
||||||
|
nbPages: 1,
|
||||||
|
hitsPerPage: 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_ALGOLIA_EMPTY: AlgoliaSearchResponse = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
hits: [],
|
||||||
|
nbHits: 0,
|
||||||
|
page: 0,
|
||||||
|
nbPages: 0,
|
||||||
|
hitsPerPage: 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
const statsWithManager = {
|
||||||
|
...mockSystemStats,
|
||||||
|
system: {
|
||||||
|
...mockSystemStats.system,
|
||||||
|
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||||
|
await route.fulfill({ json: statsWithManager })
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.featureFlags.mockServerFeatures({
|
||||||
|
'extension.manager.supports_v4': true
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/v2/customnode/installed**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_INSTALLED_PACKS })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/v2/manager/queue/status**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
history: {},
|
||||||
|
running_queue: [],
|
||||||
|
pending_queue: [],
|
||||||
|
installed_packs: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/v2/manager/queue/history**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: {} })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock Comfy Registry API (fallback when Algolia credentials are unavailable)
|
||||||
|
const registryListResponse = {
|
||||||
|
total: 3,
|
||||||
|
nodes: [MOCK_PACK_A, MOCK_PACK_B, MOCK_PACK_C],
|
||||||
|
page: 1,
|
||||||
|
limit: 64,
|
||||||
|
totalPages: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/api.comfy.org/nodes/search**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: registryListResponse })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.page.route(
|
||||||
|
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: registryListResponse })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/v2/customnode/getmappings**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: {} })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/v2/customnode/import_fail_info**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: {} })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.setup()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openManagerDialog(comfyPage: ComfyPage) {
|
||||||
|
await comfyPage.command.executeCommand('Comfy.OpenManagerDialog')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Opens the manager dialog via command', async ({ comfyPage }) => {
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Displays pack cards from search results', async ({ comfyPage }) => {
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await expect(dialog.getByText('Test Pack A')).toBeVisible()
|
||||||
|
await expect(dialog.getByText('Test Pack B')).toBeVisible()
|
||||||
|
await expect(dialog.getByText('Test Pack C')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Search filters displayed packs', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
|
||||||
|
})
|
||||||
|
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
|
||||||
|
})
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/api.comfy.org/nodes/search**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
total: 1,
|
||||||
|
nodes: [MOCK_PACK_B],
|
||||||
|
page: 1,
|
||||||
|
limit: 64,
|
||||||
|
totalPages: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const searchInput = dialog.getByPlaceholder(/search/i)
|
||||||
|
await searchInput.fill('Test Pack B')
|
||||||
|
|
||||||
|
await expect(dialog.getByText('Test Pack B')).toBeVisible()
|
||||||
|
await expect(dialog.getByText('Test Pack A')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/api.comfy.org/nodes/test-pack-a',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_PACK_A })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await dialog.getByText('Test Pack A').first().click()
|
||||||
|
|
||||||
|
await expect(dialog.getByText('Test Publisher').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Left side panel navigation tabs exist', async ({ comfyPage }) => {
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const nav = dialog.locator('nav')
|
||||||
|
await expect(nav.getByText('All Extensions')).toBeVisible()
|
||||||
|
await expect(nav.getByText('Not Installed')).toBeVisible()
|
||||||
|
await expect(nav.getByText('All Installed')).toBeVisible()
|
||||||
|
await expect(nav.getByText('Updates Available')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Switching tabs changes the content view', async ({ comfyPage }) => {
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const nav = dialog.locator('nav')
|
||||||
|
await nav.getByText('All Installed').click()
|
||||||
|
|
||||||
|
await expect(dialog.getByText('Test Pack A')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Closes via Escape key', async ({ comfyPage }) => {
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
|
await expect(dialog).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Empty search shows no results message', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
|
||||||
|
})
|
||||||
|
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||||
|
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
|
||||||
|
})
|
||||||
|
await comfyPage.page.route(
|
||||||
|
'**/api.comfy.org/nodes/search**',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
total: 0,
|
||||||
|
nodes: [],
|
||||||
|
page: 1,
|
||||||
|
limit: 64,
|
||||||
|
totalPages: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const searchInput = dialog.getByPlaceholder(/search/i)
|
||||||
|
await searchInput.fill('nonexistent-pack-xyz-999')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dialog.getByText(/no results found|try a different search/i).first()
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Search mode can be switched between packs and nodes', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await openManagerDialog(comfyPage)
|
||||||
|
|
||||||
|
const dialog = comfyPage.page.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const modeSelector = dialog.getByText('Node Pack').first()
|
||||||
|
await expect(modeSelector).toBeVisible()
|
||||||
|
|
||||||
|
await modeSelector.click()
|
||||||
|
const nodesOption = comfyPage.page.getByRole('option', { name: 'Nodes' })
|
||||||
|
await expect(nodesOption).toBeVisible()
|
||||||
|
await nodesOption.click()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -60,7 +60,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||||
await expect(dialog).not.toBeVisible()
|
await expect(dialog).toBeHidden()
|
||||||
expect(clearCalled).toBe(false)
|
expect(clearCalled).toBe(false)
|
||||||
|
|
||||||
await comfyPage.page.unroute('**/api/history')
|
await comfyPage.page.unroute('**/api/history')
|
||||||
@@ -83,7 +83,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await dialog.getByLabel('Close').click()
|
await dialog.getByLabel('Close').click()
|
||||||
await expect(dialog).not.toBeVisible()
|
await expect(dialog).toBeHidden()
|
||||||
expect(clearCalled).toBe(false)
|
expect(clearCalled).toBe(false)
|
||||||
|
|
||||||
await comfyPage.page.unroute('**/api/history')
|
await comfyPage.page.unroute('**/api/history')
|
||||||
@@ -106,7 +106,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
|||||||
const request = await clearPromise
|
const request = await clearPromise
|
||||||
expect(request.postDataJSON()).toEqual({ clear: true })
|
expect(request.postDataJSON()).toEqual({ clear: true })
|
||||||
|
|
||||||
await expect(dialog).not.toBeVisible()
|
await expect(dialog).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
|
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
|
||||||
@@ -114,7 +114,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
|||||||
const dialog = comfyPage.confirmDialog.root
|
const dialog = comfyPage.confirmDialog.root
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||||
await expect(dialog).not.toBeVisible()
|
await expect(dialog).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||||
import { mockSystemStats } from '../../fixtures/data/systemStats'
|
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||||
|
|
||||||
const MOCK_COMFYUI_VERSION = '9.99.0-e2e-test'
|
const MOCK_COMFYUI_VERSION = '9.99.0-e2e-test'
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
|||||||
await expect(dialog.root).toBeVisible()
|
await expect(dialog.root).toBeVisible()
|
||||||
|
|
||||||
await dialog.close()
|
await dialog.close()
|
||||||
await expect(dialog.root).not.toBeVisible()
|
await expect(dialog.root).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Escape key closes dialog', async ({ comfyPage }) => {
|
test('Escape key closes dialog', async ({ comfyPage }) => {
|
||||||
@@ -70,7 +70,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
|||||||
await expect(dialog.root).toBeVisible()
|
await expect(dialog.root).toBeVisible()
|
||||||
|
|
||||||
await comfyPage.page.keyboard.press('Escape')
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
await expect(dialog.root).not.toBeVisible()
|
await expect(dialog.root).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Search filters settings list', async ({ comfyPage }) => {
|
test('Search filters settings list', async ({ comfyPage }) => {
|
||||||
@@ -145,15 +145,20 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
|||||||
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
||||||
await expect(settingRow).toBeVisible()
|
await expect(settingRow).toBeVisible()
|
||||||
|
|
||||||
// Click the PrimeVue Select to open the dropdown
|
// Open the dropdown via its combobox role and verify it expanded.
|
||||||
await settingRow.locator('.p-select').click()
|
// Retry because the PrimeVue Select may re-render during search
|
||||||
const overlay = comfyPage.page.locator('.p-select-overlay')
|
// filtering, causing the first click to land on a stale element.
|
||||||
await expect(overlay).toBeVisible()
|
const select = settingRow.getByRole('combobox')
|
||||||
|
await expect(async () => {
|
||||||
|
const expanded = await select.getAttribute('aria-expanded')
|
||||||
|
if (expanded !== 'true') await select.click()
|
||||||
|
await expect(select).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
|
||||||
// Pick the option that is not the current value
|
// Pick the option that is not the current value
|
||||||
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
|
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
|
||||||
await overlay
|
await comfyPage.page
|
||||||
.locator(`.p-select-option-label:text-is("${targetValue}")`)
|
.getByRole('option', { name: targetValue, exact: true })
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Page } from '@playwright/test'
|
||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
import type { AssetInfo } from '../../../src/schemas/apiSchema'
|
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||||
import { comfyPageFixture } from '../../fixtures/ComfyPage'
|
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||||
import { TestIds } from '../../fixtures/selectors'
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
|
|
||||||
interface PublishRecord {
|
interface PublishRecord {
|
||||||
workflow_id: string
|
workflow_id: string
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
|||||||
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
||||||
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
|
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
|
||||||
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
|
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
|
||||||
await expect(textareaWidget).not.toBeVisible()
|
await expect(textareaWidget).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {
|
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {
|
||||||
@@ -25,8 +25,8 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
await node.click('collapse')
|
await node.click('collapse')
|
||||||
}
|
}
|
||||||
await expect(firstMultiline).not.toBeVisible()
|
await expect(firstMultiline).toBeHidden()
|
||||||
await expect(lastMultiline).not.toBeVisible()
|
await expect(lastMultiline).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -35,14 +35,14 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
|||||||
async ({ comfyPage }) => {
|
async ({ comfyPage }) => {
|
||||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||||
await comfyPage.nextFrame()
|
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// No DOM widget should be created by creation of interim LGraphNode objects.
|
// No DOM widget should be created by creation of interim LGraphNode objects.
|
||||||
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {
|
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {
|
||||||
const initialCount = await comfyPage.getDOMWidgetCount()
|
const initialCount = await comfyPage.domWidgets.count()
|
||||||
|
|
||||||
// TextEncodeNode1
|
// TextEncodeNode1
|
||||||
await comfyPage.page.mouse.move(618, 191)
|
await comfyPage.page.mouse.move(618, 191)
|
||||||
@@ -52,7 +52,6 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
|||||||
await comfyPage.page.mouse.up()
|
await comfyPage.page.mouse.up()
|
||||||
await comfyPage.page.keyboard.up('Alt')
|
await comfyPage.page.keyboard.up('Alt')
|
||||||
|
|
||||||
const finalCount = await comfyPage.getDOMWidgetCount()
|
await expect(comfyPage.domWidgets).toHaveCount(initialCount + 1)
|
||||||
expect(finalCount).toBe(initialCount + 1)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ test.describe('Error dialog', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const errorDialog = await triggerConfigureError(comfyPage)
|
const errorDialog = await triggerConfigureError(comfyPage)
|
||||||
await expect(errorDialog).toBeVisible()
|
await expect(errorDialog).toBeVisible()
|
||||||
await expect(errorDialog.locator('pre')).not.toBeVisible()
|
await expect(errorDialog.locator('pre')).toBeHidden()
|
||||||
|
|
||||||
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
|
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ test.describe('Error dialog', () => {
|
|||||||
await expect(reportPre).toHaveText(/\S/)
|
await expect(reportPre).toHaveText(/\S/)
|
||||||
await expect(
|
await expect(
|
||||||
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
|
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
|
||||||
).not.toBeVisible()
|
).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({
|
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({
|
||||||
@@ -100,8 +100,9 @@ test.describe('Error dialog', () => {
|
|||||||
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
|
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
|
||||||
|
|
||||||
const reportText = await errorDialog.locator('pre').textContent()
|
const reportText = await errorDialog.locator('pre').textContent()
|
||||||
const copiedText = await getClipboardText(comfyPage.page)
|
await expect
|
||||||
expect(copiedText).toBe(reportText)
|
.poll(async () => await getClipboardText(comfyPage.page))
|
||||||
|
.toBe(reportText)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should open GitHub issues search when "Find Issues" is clicked', async ({
|
test('Should open GitHub issues search when "Find Issues" is clicked', async ({
|
||||||
|
|||||||
@@ -47,11 +47,16 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
|||||||
test('Should display "Show missing models" button for missing model errors', async ({
|
test('Should display "Show missing models" button for missing model errors', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
await expect
|
||||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
.poll(() =>
|
||||||
return response.ok
|
comfyPage.page.evaluate(async (url: string) => {
|
||||||
}, comfyPage.url)
|
const response = await fetch(
|
||||||
expect(cleanupOk).toBeTruthy()
|
`${url}/api/devtools/cleanup_fake_model`
|
||||||
|
)
|
||||||
|
return response.ok
|
||||||
|
}, comfyPage.url)
|
||||||
|
)
|
||||||
|
.toBeTruthy()
|
||||||
|
|
||||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||||
|
|
||||||
@@ -95,7 +100,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
|||||||
await errorOverlay
|
await errorOverlay
|
||||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||||
.click()
|
.click()
|
||||||
await expect(errorOverlay).not.toBeVisible()
|
await expect(errorOverlay).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.canvas.click()
|
await comfyPage.canvas.click()
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
@@ -107,10 +112,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
|||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
await comfyPage.keyboard.undo()
|
await comfyPage.keyboard.undo()
|
||||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
await expect(errorOverlay).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.keyboard.redo()
|
await comfyPage.keyboard.redo()
|
||||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
await expect(errorOverlay).toBeHidden()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -151,6 +156,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||||
|
|
||||||
|
await expect(overlay).toBeHidden()
|
||||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -162,7 +168,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||||
|
|
||||||
await expect(overlay).not.toBeVisible()
|
await expect(overlay).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('"Dismiss" closes overlay without opening panel', async ({
|
test('"Dismiss" closes overlay without opening panel', async ({
|
||||||
@@ -175,10 +181,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||||
|
|
||||||
await expect(overlay).not.toBeVisible()
|
await expect(overlay).toBeHidden()
|
||||||
await expect(
|
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
|
||||||
comfyPage.page.getByTestId('properties-panel')
|
|
||||||
).not.toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||||
@@ -189,7 +193,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await overlay.getByRole('button', { name: /close/i }).click()
|
await overlay.getByRole('button', { name: /close/i }).click()
|
||||||
|
|
||||||
await expect(overlay).not.toBeVisible()
|
await expect(overlay).toBeHidden()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,18 +49,28 @@ test.describe(
|
|||||||
const input = await comfyPage.nodeOps.getNodeRefById(3)
|
const input = await comfyPage.nodeOps.getNodeRefById(3)
|
||||||
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
|
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
|
||||||
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
|
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
|
||||||
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
|
await expect
|
||||||
expect(await (await output1.getWidget(0)).getValue()).toBe('')
|
.poll(async () => (await input.getWidget(0)).getValue())
|
||||||
expect(await (await output2.getWidget(0)).getValue()).toBe('')
|
.toBe('foo')
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await output1.getWidget(0)).getValue())
|
||||||
|
.toBe('')
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await output2.getWidget(0)).getValue())
|
||||||
|
.toBe('')
|
||||||
|
|
||||||
await output1.click('title')
|
await output1.click('title')
|
||||||
|
|
||||||
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
|
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
|
||||||
await expect(async () => {
|
await expect
|
||||||
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
|
.poll(async () => (await input.getWidget(0)).getValue())
|
||||||
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
|
.toBe('foo')
|
||||||
expect(await (await output2.getWidget(0)).getValue()).toBe('')
|
await expect
|
||||||
}).toPass({ timeout: 2_000 })
|
.poll(async () => (await output1.getWidget(0)).getValue())
|
||||||
|
.toBe('foo')
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await output2.getWidget(0)).getValue())
|
||||||
|
.toBe('')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
|
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
|
||||||
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.foo))
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should not allow register command defined in other extension', async ({
|
test('Should not allow register command defined in other extension', async ({
|
||||||
@@ -60,7 +62,7 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
|
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
|
||||||
expect(await menuItem.count()).toBe(0)
|
await expect(menuItem).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should allow registering keybindings', async ({ comfyPage }) => {
|
test('Should allow registering keybindings', async ({ comfyPage }) => {
|
||||||
@@ -86,7 +88,9 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.page.keyboard.press('k')
|
await comfyPage.page.keyboard.press('k')
|
||||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Settings', () => {
|
test.describe('Settings', () => {
|
||||||
@@ -109,16 +113,20 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
// onChange is called when the setting is first added
|
// onChange is called when the setting is first added
|
||||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
|
await expect
|
||||||
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||||
'Hello, world!'
|
.toBe(1)
|
||||||
)
|
await expect
|
||||||
|
.poll(() => comfyPage.settings.getSetting('TestSetting'))
|
||||||
|
.toBe('Hello, world!')
|
||||||
|
|
||||||
await comfyPage.settings.setSetting('TestSetting', 'Hello, universe!')
|
await comfyPage.settings.setSetting('TestSetting', 'Hello, universe!')
|
||||||
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
|
await expect
|
||||||
'Hello, universe!'
|
.poll(() => comfyPage.settings.getSetting('TestSetting'))
|
||||||
)
|
.toBe('Hello, universe!')
|
||||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||||
|
.toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should allow setting boolean settings', async ({ comfyPage }) => {
|
test('Should allow setting boolean settings', async ({ comfyPage }) => {
|
||||||
@@ -140,17 +148,21 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
|
await expect
|
||||||
false
|
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
|
||||||
)
|
.toBe(false)
|
||||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
await comfyPage.settingDialog.open()
|
await comfyPage.settingDialog.open()
|
||||||
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
|
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
|
||||||
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
|
await expect
|
||||||
true
|
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
|
||||||
)
|
.toBe(true)
|
||||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
|
await expect
|
||||||
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||||
|
.toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Passing through attrs to setting components', () => {
|
test.describe('Passing through attrs to setting components', () => {
|
||||||
@@ -228,12 +240,15 @@ test.describe('Topbar commands', () => {
|
|||||||
.getByText('TestSetting Test')
|
.getByText('TestSetting Test')
|
||||||
.locator(selector)
|
.locator(selector)
|
||||||
|
|
||||||
const isDisabled = await component.evaluate((el) =>
|
await expect
|
||||||
el.tagName === 'INPUT'
|
.poll(() =>
|
||||||
? (el as HTMLInputElement).disabled
|
component.evaluate((el) =>
|
||||||
: el.classList.contains('p-disabled')
|
el.tagName === 'INPUT'
|
||||||
)
|
? (el as HTMLInputElement).disabled
|
||||||
expect(isDisabled).toBe(true)
|
: el.classList.contains('p-disabled')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -258,7 +273,7 @@ test.describe('Topbar commands', () => {
|
|||||||
await comfyPage.settingDialog.goToAboutPanel()
|
await comfyPage.settingDialog.goToAboutPanel()
|
||||||
const badge = comfyPage.page.locator('.about-badge').last()
|
const badge = comfyPage.page.locator('.about-badge').last()
|
||||||
expect(badge).toBeDefined()
|
expect(badge).toBeDefined()
|
||||||
expect(await badge.textContent()).toContain('Test Badge')
|
await expect(badge).toContainText('Test Badge')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -276,11 +291,13 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.nodeOps.fillPromptDialog('Hello, world!')
|
await comfyPage.nodeOps.fillPromptDialog('Hello, world!')
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.page.evaluate(
|
.poll(() =>
|
||||||
() => (window as unknown as Record<string, unknown>)['value']
|
comfyPage.page.evaluate(
|
||||||
|
() => (window as unknown as Record<string, unknown>)['value']
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toBe('Hello, world!')
|
.toBe('Hello, world!')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should allow showing a confirmation dialog', async ({
|
test('Should allow showing a confirmation dialog', async ({
|
||||||
@@ -298,11 +315,13 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.confirmDialog.click('confirm')
|
await comfyPage.confirmDialog.click('confirm')
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.page.evaluate(
|
.poll(() =>
|
||||||
() => (window as unknown as Record<string, unknown>)['value']
|
comfyPage.page.evaluate(
|
||||||
|
() => (window as unknown as Record<string, unknown>)['value']
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toBe(true)
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
|
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
|
||||||
@@ -319,11 +338,13 @@ test.describe('Topbar commands', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await comfyPage.confirmDialog.click('reject')
|
await comfyPage.confirmDialog.click('reject')
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.page.evaluate(
|
.poll(() =>
|
||||||
() => (window as unknown as Record<string, unknown>)['value']
|
comfyPage.page.evaluate(
|
||||||
|
() => (window as unknown as Record<string, unknown>)['value']
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toBeNull()
|
.toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -363,14 +384,16 @@ test.describe('Topbar commands', () => {
|
|||||||
)
|
)
|
||||||
await toolboxButton.click()
|
await toolboxButton.click()
|
||||||
|
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.page.evaluate(
|
.poll(() =>
|
||||||
() =>
|
comfyPage.page.evaluate(
|
||||||
(window as unknown as Record<string, unknown>)[
|
() =>
|
||||||
'selectionCommandExecuted'
|
(window as unknown as Record<string, unknown>)[
|
||||||
]
|
'selectionCommandExecuted'
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toBe(true)
|
.toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,31 +59,30 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the captured messages
|
|
||||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
|
||||||
|
|
||||||
// Verify client sent feature flags
|
// Verify client sent feature flags
|
||||||
expect(messages!.clientFeatureFlags).toBeTruthy()
|
await expect(async () => {
|
||||||
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
const flags = await newPage.evaluate(
|
||||||
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
() => window.__capturedMessages?.clientFeatureFlags
|
||||||
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
|
)
|
||||||
'supports_preview_metadata'
|
expect(flags).not.toBeNull()
|
||||||
)
|
expect(flags?.type).toBe('feature_flags')
|
||||||
expect(
|
expect(flags?.data).not.toBeNull()
|
||||||
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
expect(flags?.data).toHaveProperty('supports_preview_metadata')
|
||||||
).toBe('boolean')
|
expect(typeof flags?.data?.supports_preview_metadata).toBe('boolean')
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
|
||||||
// Verify server sent feature flags back
|
// Verify server sent feature flags back
|
||||||
expect(messages!.serverFeatureFlags).toBeTruthy()
|
await expect(async () => {
|
||||||
expect(messages!.serverFeatureFlags).toHaveProperty(
|
const flags = await newPage.evaluate(
|
||||||
'supports_preview_metadata'
|
() => window.__capturedMessages?.serverFeatureFlags
|
||||||
)
|
)
|
||||||
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
expect(flags).not.toBeNull()
|
||||||
'boolean'
|
expect(flags).toHaveProperty('supports_preview_metadata')
|
||||||
)
|
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
|
||||||
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
expect(flags).toHaveProperty('max_upload_size')
|
||||||
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
expect(typeof flags?.max_upload_size).toBe('number')
|
||||||
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
expect(Object.keys(flags ?? {}).length).toBeGreaterThan(0)
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
|
||||||
await newPage.close()
|
await newPage.close()
|
||||||
})
|
})
|
||||||
@@ -91,37 +90,44 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
test('Server feature flags are received and accessible', async ({
|
test('Server feature flags are received and accessible', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Get the actual server feature flags from the backend
|
|
||||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
|
||||||
return window.app!.api.serverFeatureFlags.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Verify we received real feature flags from the backend
|
// Verify we received real feature flags from the backend
|
||||||
expect(serverFlags).toBeTruthy()
|
await expect(async () => {
|
||||||
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
|
const flags = await comfyPage.page.evaluate(
|
||||||
|
() => window.app!.api.serverFeatureFlags.value
|
||||||
// The backend should send feature flags
|
)
|
||||||
expect(serverFlags).toHaveProperty('supports_preview_metadata')
|
expect(flags).not.toBeNull()
|
||||||
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
|
expect(Object.keys(flags).length).toBeGreaterThan(0)
|
||||||
expect(serverFlags).toHaveProperty('max_upload_size')
|
// The backend should send feature flags
|
||||||
expect(typeof serverFlags.max_upload_size).toBe('number')
|
expect(flags).toHaveProperty('supports_preview_metadata')
|
||||||
|
expect(typeof flags.supports_preview_metadata).toBe('boolean')
|
||||||
|
expect(flags).toHaveProperty('max_upload_size')
|
||||||
|
expect(typeof flags.max_upload_size).toBe('number')
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serverSupportsFeature method works with real backend flags', async ({
|
test('serverSupportsFeature method works with real backend flags', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Test serverSupportsFeature with real backend flags
|
// Test serverSupportsFeature with real backend flags
|
||||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
await expect
|
||||||
return window.app!.api.serverSupportsFeature('supports_preview_metadata')
|
.poll(() =>
|
||||||
})
|
comfyPage.page.evaluate(
|
||||||
// The method should return a boolean based on the backend's value
|
() =>
|
||||||
expect(typeof supportsPreviewMetadata).toBe('boolean')
|
typeof window.app!.api.serverSupportsFeature(
|
||||||
|
'supports_preview_metadata'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe('boolean')
|
||||||
|
|
||||||
// Test non-existent feature - should always return false
|
// Test non-existent feature - should always return false
|
||||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
await expect
|
||||||
return window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
|
.poll(() =>
|
||||||
})
|
comfyPage.page.evaluate(() =>
|
||||||
expect(supportsNonExistent).toBe(false)
|
window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
// Test that the method only returns true for boolean true values
|
// Test that the method only returns true for boolean true values
|
||||||
const testResults = await comfyPage.page.evaluate(() => {
|
const testResults = await comfyPage.page.evaluate(() => {
|
||||||
@@ -160,41 +166,51 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Test getServerFeature method
|
// Test getServerFeature method
|
||||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
await expect
|
||||||
return window.app!.api.getServerFeature('supports_preview_metadata')
|
.poll(() =>
|
||||||
})
|
comfyPage.page.evaluate(
|
||||||
expect(typeof previewMetadataValue).toBe('boolean')
|
() =>
|
||||||
|
typeof window.app!.api.getServerFeature('supports_preview_metadata')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe('boolean')
|
||||||
|
|
||||||
// Test getting max_upload_size
|
// Test getting max_upload_size
|
||||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
await expect(async () => {
|
||||||
return window.app!.api.getServerFeature('max_upload_size')
|
const maxUpload = await comfyPage.page.evaluate(() =>
|
||||||
})
|
window.app!.api.getServerFeature('max_upload_size')
|
||||||
expect(typeof maxUploadSize).toBe('number')
|
)
|
||||||
expect(maxUploadSize).toBeGreaterThan(0)
|
expect(typeof maxUpload).toBe('number')
|
||||||
|
expect(maxUpload as number).toBeGreaterThan(0)
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
|
||||||
// Test getServerFeature with default value for non-existent feature
|
// Test getServerFeature with default value for non-existent feature
|
||||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
await expect
|
||||||
return window.app!.api.getServerFeature(
|
.poll(() =>
|
||||||
'non_existent_feature_xyz',
|
comfyPage.page.evaluate(() =>
|
||||||
'default'
|
window.app!.api.getServerFeature(
|
||||||
|
'non_existent_feature_xyz',
|
||||||
|
'default'
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
.toBe('default')
|
||||||
expect(defaultValue).toBe('default')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getServerFeatures returns all backend feature flags', async ({
|
test('getServerFeatures returns all backend feature flags', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Test getServerFeatures returns all flags
|
// Test getServerFeatures returns all flags
|
||||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
await expect(async () => {
|
||||||
return window.app!.api.getServerFeatures()
|
const features = await comfyPage.page.evaluate(() =>
|
||||||
})
|
window.app!.api.getServerFeatures()
|
||||||
|
)
|
||||||
expect(allFeatures).toBeTruthy()
|
expect(features).not.toBeNull()
|
||||||
expect(allFeatures).toHaveProperty('supports_preview_metadata')
|
expect(features).toHaveProperty('supports_preview_metadata')
|
||||||
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
|
expect(typeof features.supports_preview_metadata).toBe('boolean')
|
||||||
expect(allFeatures).toHaveProperty('max_upload_size')
|
expect(features).toHaveProperty('max_upload_size')
|
||||||
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
|
expect(Object.keys(features).length).toBeGreaterThan(0)
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||||
@@ -324,26 +340,22 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get readiness state
|
|
||||||
const readiness = await newPage.evaluate(() => {
|
|
||||||
return {
|
|
||||||
...window.__appReadiness,
|
|
||||||
currentFlags: window.app!.api.serverFeatureFlags.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Verify feature flags are available
|
// Verify feature flags are available
|
||||||
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
|
await expect(async () => {
|
||||||
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
|
const flags = await newPage.evaluate(
|
||||||
'boolean'
|
() => window.app!.api.serverFeatureFlags.value
|
||||||
)
|
)
|
||||||
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
|
expect(flags).toHaveProperty('supports_preview_metadata')
|
||||||
|
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
|
||||||
|
expect(flags).toHaveProperty('max_upload_size')
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
|
||||||
// Verify feature flags were received (we detected them via polling)
|
// Verify feature flags were received and API was initialized
|
||||||
expect(readiness.featureFlagsReceived).toBe(true)
|
await expect(async () => {
|
||||||
|
const readiness = await newPage.evaluate(() => window.__appReadiness)
|
||||||
// Verify API was initialized (feature flags require API)
|
expect(readiness?.featureFlagsReceived).toBe(true)
|
||||||
expect(readiness.apiInitialized).toBe(true)
|
expect(readiness?.apiInitialized).toBe(true)
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
|
|
||||||
await newPage.close()
|
await newPage.close()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await comfyPage.setFocusMode(true)
|
await comfyPage.setFocusMode(true)
|
||||||
|
|
||||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Focus mode restores UI chrome', async ({ comfyPage }) => {
|
test('Focus mode restores UI chrome', async ({ comfyPage }) => {
|
||||||
await comfyPage.setFocusMode(true)
|
await comfyPage.setFocusMode(true)
|
||||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.setFocusMode(false)
|
await comfyPage.setFocusMode(false)
|
||||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||||
@@ -29,11 +29,9 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
|||||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||||
|
|
||||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||||
await comfyPage.nextFrame()
|
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
|
||||||
|
|
||||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -43,7 +41,7 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
await comfyPage.setFocusMode(true)
|
await comfyPage.setFocusMode(true)
|
||||||
|
|
||||||
await expect(topMenu).not.toBeVisible()
|
await expect(topMenu).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
|
test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
|
||||||
@@ -54,12 +52,12 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
|||||||
|
|
||||||
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
|
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
|
||||||
await comfyPage.setFocusMode(true)
|
await comfyPage.setFocusMode(true)
|
||||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||||
|
|
||||||
await comfyPage.setFocusMode(false)
|
await comfyPage.setFocusMode(false)
|
||||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||||
|
|
||||||
await comfyPage.setFocusMode(true)
|
await comfyPage.setFocusMode(true)
|
||||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,17 +11,19 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
|||||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
|
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
|
||||||
test('Fix link input slots', async ({ comfyPage }) => {
|
test('Fix link input slots', async ({ comfyPage }) => {
|
||||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.page.evaluate(() => {
|
.poll(() =>
|
||||||
return window.app!.graph!.links.get(1)?.target_slot
|
comfyPage.page.evaluate(() => {
|
||||||
})
|
return window.app!.graph!.links.get(1)?.target_slot
|
||||||
).toBe(1)
|
})
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Validate workflow links', async ({ comfyPage }) => {
|
test('Validate workflow links', async ({ comfyPage }) => {
|
||||||
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
|
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
|
||||||
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
||||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
|
await expect(comfyPage.toast.visibleToasts).toHaveCount(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||||
@@ -36,72 +38,77 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||||
|
|
||||||
const result = await comfyPage.page.evaluate(() => {
|
function evaluateGraph() {
|
||||||
const graph = window.app!.graph!
|
return comfyPage.page.evaluate(() => {
|
||||||
|
const graph = window.app!.graph!
|
||||||
|
|
||||||
const subgraph = graph.subgraphs.values().next().value
|
const subgraph = graph.subgraphs.values().next().value
|
||||||
if (!subgraph) return { error: 'No subgraph found' }
|
if (!subgraph) return { error: 'No subgraph found' }
|
||||||
|
|
||||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||||
const switchCfg = subgraph.getNodeById(120)
|
const switchCfg = subgraph.getNodeById(120)
|
||||||
const ksampler85 = subgraph.getNodeById(85)
|
const ksampler85 = subgraph.getNodeById(85)
|
||||||
const ksampler86 = subgraph.getNodeById(86)
|
const ksampler86 = subgraph.getNodeById(86)
|
||||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||||
return { error: 'Required nodes not found' }
|
return { error: 'Required nodes not found' }
|
||||||
|
|
||||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||||
const cfgInput85 = ksampler85.inputs.find(
|
const cfgInput85 = ksampler85.inputs.find(
|
||||||
(i: { name: string }) => i.name === 'cfg'
|
(i: { name: string }) => i.name === 'cfg'
|
||||||
|
)
|
||||||
|
const cfgInput86 = ksampler86.inputs.find(
|
||||||
|
(i: { name: string }) => i.name === 'cfg'
|
||||||
|
)
|
||||||
|
const cfg85Linked = cfgInput85?.link != null
|
||||||
|
const cfg86Linked = cfgInput86?.link != null
|
||||||
|
|
||||||
|
// Verify the surviving links exist in the subgraph link map
|
||||||
|
const cfg85LinkValid =
|
||||||
|
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||||
|
const cfg86LinkValid =
|
||||||
|
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||||
|
|
||||||
|
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||||
|
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||||
|
|
||||||
|
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||||
|
let cfgLinkToNode85Count = 0
|
||||||
|
for (const link of subgraph.links.values()) {
|
||||||
|
if (link.origin_id === 120 && link.target_id === 85)
|
||||||
|
cfgLinkToNode85Count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cfg85Linked,
|
||||||
|
cfg86Linked,
|
||||||
|
cfg85LinkValid,
|
||||||
|
cfg86LinkValid,
|
||||||
|
cfg85LinkId: cfgInput85?.link ?? null,
|
||||||
|
cfg86LinkId: cfgInput86?.link ?? null,
|
||||||
|
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||||
|
switchOutputLinkCount,
|
||||||
|
cfgLinkToNode85Count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll graph state once, then assert all properties
|
||||||
|
await expect(async () => {
|
||||||
|
const r = await evaluateGraph()
|
||||||
|
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||||
|
expect(r.cfg85Linked).toBe(true)
|
||||||
|
expect(r.cfg86Linked).toBe(true)
|
||||||
|
// Links must exist in the subgraph link map
|
||||||
|
expect(r.cfg85LinkValid).toBe(true)
|
||||||
|
expect(r.cfg86LinkValid).toBe(true)
|
||||||
|
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||||
|
expect(r.switchOutputLinkCount).toBe(2)
|
||||||
|
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||||
|
expect(r.cfgLinkToNode85Count).toBe(1)
|
||||||
|
// Output link IDs must match the input link IDs (source/target integrity)
|
||||||
|
expect(r.switchOutputLinkIds).toEqual(
|
||||||
|
expect.arrayContaining([r.cfg85LinkId, r.cfg86LinkId])
|
||||||
)
|
)
|
||||||
const cfgInput86 = ksampler86.inputs.find(
|
}).toPass({ timeout: 5000 })
|
||||||
(i: { name: string }) => i.name === 'cfg'
|
|
||||||
)
|
|
||||||
const cfg85Linked = cfgInput85?.link != null
|
|
||||||
const cfg86Linked = cfgInput86?.link != null
|
|
||||||
|
|
||||||
// Verify the surviving links exist in the subgraph link map
|
|
||||||
const cfg85LinkValid =
|
|
||||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
|
||||||
const cfg86LinkValid =
|
|
||||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
|
||||||
|
|
||||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
|
||||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
|
||||||
|
|
||||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
|
||||||
let cfgLinkToNode85Count = 0
|
|
||||||
for (const link of subgraph.links.values()) {
|
|
||||||
if (link.origin_id === 120 && link.target_id === 85)
|
|
||||||
cfgLinkToNode85Count++
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cfg85Linked,
|
|
||||||
cfg86Linked,
|
|
||||||
cfg85LinkValid,
|
|
||||||
cfg86LinkValid,
|
|
||||||
cfg85LinkId: cfgInput85?.link ?? null,
|
|
||||||
cfg86LinkId: cfgInput86?.link ?? null,
|
|
||||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
|
||||||
switchOutputLinkCount,
|
|
||||||
cfgLinkToNode85Count
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).not.toHaveProperty('error')
|
|
||||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
|
||||||
expect(result.cfg85Linked).toBe(true)
|
|
||||||
expect(result.cfg86Linked).toBe(true)
|
|
||||||
// Links must exist in the subgraph link map
|
|
||||||
expect(result.cfg85LinkValid).toBe(true)
|
|
||||||
expect(result.cfg86LinkValid).toBe(true)
|
|
||||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
|
||||||
expect(result.switchOutputLinkCount).toBe(2)
|
|
||||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
|
||||||
expect(result.cfgLinkToNode85Count).toBe(1)
|
|
||||||
// Output link IDs must match the input link IDs (source/target integrity)
|
|
||||||
expect(result.switchOutputLinkIds).toEqual(
|
|
||||||
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,18 +31,18 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
|||||||
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
||||||
return window.LiteGraph!.HIDDEN_LINK
|
return window.LiteGraph!.HIDDEN_LINK
|
||||||
})
|
})
|
||||||
expect(await comfyPage.settings.getSetting('Comfy.LinkRenderMode')).toBe(
|
await expect
|
||||||
hiddenLinkRenderMode
|
.poll(() => comfyPage.settings.getSetting('Comfy.LinkRenderMode'))
|
||||||
)
|
.toBe(hiddenLinkRenderMode)
|
||||||
|
|
||||||
await button.click()
|
await button.click()
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
'canvas-with-visible-links.png'
|
'canvas-with-visible-links.png'
|
||||||
)
|
)
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.settings.getSetting('Comfy.LinkRenderMode')
|
.poll(() => comfyPage.settings.getSetting('Comfy.LinkRenderMode'))
|
||||||
).not.toBe(hiddenLinkRenderMode)
|
.not.toBe(hiddenLinkRenderMode)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,9 +92,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
|||||||
// Click backdrop to close
|
// Click backdrop to close
|
||||||
const backdrop = comfyPage.page.locator('.fixed.inset-0').first()
|
const backdrop = comfyPage.page.locator('.fixed.inset-0').first()
|
||||||
await backdrop.click()
|
await backdrop.click()
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
// Modal should be hidden
|
// Modal should be hidden
|
||||||
await expect(zoomModal).not.toBeVisible()
|
await expect(zoomModal).toBeHidden()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,17 +28,20 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
|||||||
await comfyPage.clipboard.paste()
|
await comfyPage.clipboard.paste()
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
const positions = await comfyPage.page.evaluate(() =>
|
const getGroupPositions = () =>
|
||||||
window.app!.graph.groups.map((g: { pos: number[] }) => ({
|
comfyPage.page.evaluate(() =>
|
||||||
x: g.pos[0],
|
window.app!.graph.groups.map((g: { pos: number[] }) => ({
|
||||||
y: g.pos[1]
|
x: g.pos[0],
|
||||||
}))
|
y: g.pos[1]
|
||||||
)
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
expect(positions).toHaveLength(2)
|
await expect.poll(getGroupPositions).toHaveLength(2)
|
||||||
const dx = Math.abs(positions[0].x - positions[1].x)
|
|
||||||
const dy = Math.abs(positions[0].y - positions[1].y)
|
await expect(async () => {
|
||||||
expect(dx).toBeCloseTo(50, 0)
|
const positions = await getGroupPositions()
|
||||||
expect(dy).toBeCloseTo(15, 0)
|
expect(Math.abs(positions[0].x - positions[1].x)).toBeCloseTo(50, 0)
|
||||||
|
expect(Math.abs(positions[0].y - positions[1].y)).toBeCloseTo(15, 0)
|
||||||
|
}).toPass({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { expect } from '@playwright/test'
|
|
||||||
|
|
||||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
|
|
||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
import {
|
||||||
|
comfyExpect as expect,
|
||||||
|
comfyPageFixture as test
|
||||||
|
} from '@e2e/fixtures/ComfyPage'
|
||||||
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||||
import { TestIds } from '@e2e/fixtures/selectors'
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||||
@@ -32,7 +33,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
test('Is added to node library sidebar', async ({
|
test('Is added to node library sidebar', async ({
|
||||||
comfyPage: _comfyPage
|
comfyPage: _comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
expect(await libraryTab.getFolder(groupNodeCategory).count()).toBe(1)
|
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can be added to canvas using node library sidebar', async ({
|
test('Can be added to canvas using node library sidebar', async ({
|
||||||
@@ -45,9 +46,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
await libraryTab.getNode(groupNodeName).click()
|
await libraryTab.getNode(groupNodeName).click()
|
||||||
|
|
||||||
// Verify the node is added to the canvas
|
// Verify the node is added to the canvas
|
||||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
await expect
|
||||||
initialNodeCount + 1
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||||
)
|
.toBe(initialNodeCount + 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||||
@@ -58,11 +59,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
.click()
|
.click()
|
||||||
|
|
||||||
// Verify the node is added to the bookmarks tab
|
// Verify the node is added to the bookmarks tab
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
.poll(() =>
|
||||||
).toEqual([groupNodeBookmarkName])
|
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||||
|
)
|
||||||
|
.toEqual([groupNodeBookmarkName])
|
||||||
// Verify the bookmark node with the same name is added to the tree
|
// Verify the bookmark node with the same name is added to the tree
|
||||||
expect(await libraryTab.getNode(groupNodeName).count()).not.toBe(0)
|
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
|
||||||
|
|
||||||
// Unbookmark the node
|
// Unbookmark the node
|
||||||
await libraryTab
|
await libraryTab
|
||||||
@@ -72,9 +75,11 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
.click()
|
.click()
|
||||||
|
|
||||||
// Verify the node is removed from the bookmarks tab
|
// Verify the node is removed from the bookmarks tab
|
||||||
expect(
|
await expect
|
||||||
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
.poll(() =>
|
||||||
).toHaveLength(0)
|
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||||
|
)
|
||||||
|
.toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||||
@@ -83,10 +88,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
.getNode(groupNodeName)
|
.getNode(groupNodeName)
|
||||||
.locator('.bookmark-button')
|
.locator('.bookmark-button')
|
||||||
.click()
|
.click()
|
||||||
await comfyPage.page.hover('.p-tree-node-label.tree-explorer-node-label')
|
await comfyPage.page
|
||||||
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(
|
.locator('.p-tree-node-label.tree-explorer-node-label')
|
||||||
true
|
.first()
|
||||||
)
|
.hover()
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.locator('.node-lib-node-preview')
|
||||||
|
).toBeVisible()
|
||||||
await libraryTab
|
await libraryTab
|
||||||
.getNode(groupNodeName)
|
.getNode(groupNodeName)
|
||||||
.locator('.bookmark-button')
|
.locator('.bookmark-button')
|
||||||
@@ -94,6 +102,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
.click()
|
.click()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'Can be added to canvas using search',
|
'Can be added to canvas using search',
|
||||||
{ tag: '@screenshot' },
|
{ tag: '@screenshot' },
|
||||||
@@ -147,12 +156,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
|
|
||||||
const manage1 = await group1.manageGroupNode()
|
const manage1 = await group1.manageGroupNode()
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
expect(await manage1.getSelectedNodeType()).toBe('g1')
|
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
|
||||||
await manage1.close()
|
await manage1.close()
|
||||||
await expect(manage1.root).not.toBeVisible()
|
await expect(manage1.root).toBeHidden()
|
||||||
|
|
||||||
const manage2 = await group2.manageGroupNode()
|
const manage2 = await group2.manageGroupNode()
|
||||||
expect(await manage2.getSelectedNodeType()).toBe('g2')
|
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||||
@@ -166,24 +175,31 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
const groupNodeId = 19
|
const groupNodeId = 19
|
||||||
const groupNodeName = 'two_VAE_decode'
|
const groupNodeName = 'two_VAE_decode'
|
||||||
|
|
||||||
const totalInputCount = await comfyPage.page.evaluate((nodeName) => {
|
|
||||||
const {
|
|
||||||
extra: { groupNodes }
|
|
||||||
} = window.app!.graph!
|
|
||||||
const { nodes } = groupNodes![nodeName]
|
|
||||||
return nodes.reduce((acc, node) => acc + (node.inputs?.length ?? 0), 0)
|
|
||||||
}, groupNodeName)
|
|
||||||
|
|
||||||
const visibleInputCount = await comfyPage.page.evaluate((id) => {
|
|
||||||
const node = window.app!.graph!.getNodeById(id)
|
|
||||||
return node!.inputs.length
|
|
||||||
}, groupNodeId)
|
|
||||||
|
|
||||||
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
|
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
|
||||||
expect(totalInputCount).toBe(4)
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
comfyPage.page.evaluate((nodeName) => {
|
||||||
|
const {
|
||||||
|
extra: { groupNodes }
|
||||||
|
} = window.app!.graph!
|
||||||
|
const { nodes } = groupNodes![nodeName]
|
||||||
|
return nodes.reduce(
|
||||||
|
(acc, node) => acc + (node.inputs?.length ?? 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}, groupNodeName)
|
||||||
|
)
|
||||||
|
.toBe(4)
|
||||||
|
|
||||||
// Verify there are 2 visible inputs (2 have been hidden in config)
|
// Verify there are 2 visible inputs (2 have been hidden in config)
|
||||||
expect(visibleInputCount).toBe(2)
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
comfyPage.page.evaluate((id) => {
|
||||||
|
const node = window.app!.graph!.getNodeById(id)
|
||||||
|
return node!.inputs.length
|
||||||
|
}, groupNodeId)
|
||||||
|
)
|
||||||
|
.toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||||
@@ -210,7 +226,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
// Connect node to group
|
// Connect node to group
|
||||||
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
|
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
|
||||||
const input = await ckpt.connectOutput(0, groupNode, 0)
|
const input = await ckpt.connectOutput(0, groupNode, 0)
|
||||||
expect(await input.getLinkCount()).toBe(1)
|
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||||
// Modify the group node via manage dialog
|
// Modify the group node via manage dialog
|
||||||
const manage = await groupNode.manageGroupNode()
|
const manage = await groupNode.manageGroupNode()
|
||||||
await manage.selectNode('KSampler')
|
await manage.selectNode('KSampler')
|
||||||
@@ -219,17 +235,17 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
await manage.save()
|
await manage.save()
|
||||||
await manage.close()
|
await manage.close()
|
||||||
// Ensure the link is still present
|
// Ensure the link is still present
|
||||||
expect(await input.getLinkCount()).toBe(1)
|
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||||
).not.toBeVisible()
|
).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Copy and paste', () => {
|
test.describe('Copy and paste', () => {
|
||||||
@@ -261,8 +277,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
expect(
|
expect(
|
||||||
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
|
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
|
||||||
).toHaveLength(expectedCount)
|
).toHaveLength(expectedCount)
|
||||||
expect(await isRegisteredLitegraph(comfyPage)).toBe(true)
|
await expect.poll(() => isRegisteredLitegraph(comfyPage)).toBe(true)
|
||||||
expect(await isRegisteredNodeDefStore(comfyPage)).toBe(true)
|
await expect.poll(() => isRegisteredNodeDefStore(comfyPage)).toBe(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
@@ -333,18 +349,19 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
|||||||
|
|
||||||
test.describe('Keybindings', () => {
|
test.describe('Keybindings', () => {
|
||||||
test('Convert to group node, no selection', async ({ comfyPage }) => {
|
test('Convert to group node, no selection', async ({ comfyPage }) => {
|
||||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
|
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||||
await comfyPage.page.keyboard.press('Alt+g')
|
await comfyPage.page.keyboard.press('Alt+g')
|
||||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
|
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
|
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
|
||||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
|
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||||
await comfyPage.canvas.click({
|
await comfyPage.canvas.click({
|
||||||
position: DefaultGraphPositions.textEncodeNode1
|
position: DefaultGraphPositions.textEncodeNode1
|
||||||
})
|
})
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
await comfyPage.page.keyboard.press('Alt+g')
|
await comfyPage.page.keyboard.press('Alt+g')
|
||||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
|
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,11 +66,14 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
|||||||
await comfyPage.canvas.click({ position: outerPos })
|
await comfyPage.canvas.click({ position: outerPos })
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
const counts = await getSelectionCounts(comfyPage)
|
|
||||||
// Outer Group + Inner Group + 1 node = 3 items
|
// Outer Group + Inner Group + 1 node = 3 items
|
||||||
expect(counts.selectedItemCount).toBe(3)
|
await expect
|
||||||
expect(counts.selectedGroupCount).toBe(2)
|
.poll(() => getSelectionCounts(comfyPage))
|
||||||
expect(counts.selectedNodeCount).toBe(1)
|
.toMatchObject({
|
||||||
|
selectedItemCount: 3,
|
||||||
|
selectedGroupCount: 2,
|
||||||
|
selectedNodeCount: 1
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Setting disabled: clicking outer group selects only the group', async ({
|
test('Setting disabled: clicking outer group selects only the group', async ({
|
||||||
@@ -87,10 +90,13 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
|||||||
await comfyPage.canvas.click({ position: outerPos })
|
await comfyPage.canvas.click({ position: outerPos })
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
const counts = await getSelectionCounts(comfyPage)
|
await expect
|
||||||
expect(counts.selectedItemCount).toBe(1)
|
.poll(() => getSelectionCounts(comfyPage))
|
||||||
expect(counts.selectedGroupCount).toBe(1)
|
.toMatchObject({
|
||||||
expect(counts.selectedNodeCount).toBe(0)
|
selectedItemCount: 1,
|
||||||
|
selectedGroupCount: 1,
|
||||||
|
selectedNodeCount: 0
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Deselecting outer group deselects all children', async ({
|
test('Deselecting outer group deselects all children', async ({
|
||||||
@@ -108,8 +114,9 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
|||||||
await comfyPage.canvas.click({ position: outerPos })
|
await comfyPage.canvas.click({ position: outerPos })
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
let counts = await getSelectionCounts(comfyPage)
|
await expect
|
||||||
expect(counts.selectedItemCount).toBe(3)
|
.poll(() => getSelectionCounts(comfyPage))
|
||||||
|
.toMatchObject({ selectedItemCount: 3 })
|
||||||
|
|
||||||
// Deselect all via page.evaluate to avoid UI overlay interception
|
// Deselect all via page.evaluate to avoid UI overlay interception
|
||||||
await comfyPage.page.evaluate(() => {
|
await comfyPage.page.evaluate(() => {
|
||||||
@@ -117,7 +124,8 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
|||||||
})
|
})
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
counts = await getSelectionCounts(comfyPage)
|
await expect
|
||||||
expect(counts.selectedItemCount).toBe(0)
|
.poll(() => getSelectionCounts(comfyPage))
|
||||||
|
.toMatchObject({ selectedItemCount: 0 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
test.describe('Image Compare', () => {
|
test.describe('Image Compare', { tag: '@widget' }, () => {
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||||
@@ -21,7 +22,12 @@ test.describe('Image Compare', () => {
|
|||||||
|
|
||||||
async function setImageCompareValue(
|
async function setImageCompareValue(
|
||||||
comfyPage: ComfyPage,
|
comfyPage: ComfyPage,
|
||||||
value: { beforeImages: string[]; afterImages: string[] }
|
value: {
|
||||||
|
beforeImages: string[]
|
||||||
|
afterImages: string[]
|
||||||
|
beforeAlt?: string
|
||||||
|
afterAlt?: string
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
await comfyPage.page.evaluate(
|
await comfyPage.page.evaluate(
|
||||||
({ value }) => {
|
({ value }) => {
|
||||||
@@ -37,6 +43,48 @@ test.describe('Image Compare', () => {
|
|||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveToPercentage(
|
||||||
|
page: Page,
|
||||||
|
containerLocator: Locator,
|
||||||
|
percentage: number
|
||||||
|
) {
|
||||||
|
const box = await containerLocator.boundingBox()
|
||||||
|
if (!box) throw new Error('Container not found')
|
||||||
|
await page.mouse.move(
|
||||||
|
box.x + box.width * (percentage / 100),
|
||||||
|
box.y + box.height / 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForImagesLoaded(node: Locator) {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
node.evaluate((el) => {
|
||||||
|
const imgs = el.querySelectorAll('img')
|
||||||
|
return (
|
||||||
|
imgs.length > 0 &&
|
||||||
|
Array.from(imgs).every(
|
||||||
|
(img) => img.complete && img.naturalWidth > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getClipPathInsetRightPercent(imgLocator: Locator) {
|
||||||
|
return imgLocator.evaluate((el) => {
|
||||||
|
// Accessing raw style avoids cross-browser getComputedStyle normalization issues
|
||||||
|
// Format is uniformly "inset(0 60% 0 0)" per Vue runtime inline style bindings
|
||||||
|
const parts = (el as HTMLElement).style.clipPath.split(' ')
|
||||||
|
return parts.length > 1 ? parseFloat(parts[1]) : -1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'Shows empty state when no images are set',
|
'Shows empty state when no images are set',
|
||||||
{ tag: '@smoke' },
|
{ tag: '@smoke' },
|
||||||
@@ -46,10 +94,14 @@ test.describe('Image Compare', () => {
|
|||||||
|
|
||||||
await expect(node).toContainText('No images to compare')
|
await expect(node).toContainText('No images to compare')
|
||||||
await expect(node.locator('img')).toHaveCount(0)
|
await expect(node.locator('img')).toHaveCount(0)
|
||||||
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
|
await expect(node.getByRole('presentation')).toHaveCount(0)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slider defaults
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'Slider defaults to 50% with both images set',
|
'Slider defaults to 50% with both images set',
|
||||||
{ tag: ['@smoke', '@screenshot'] },
|
{ tag: ['@smoke', '@screenshot'] },
|
||||||
@@ -67,15 +119,444 @@ test.describe('Image Compare', () => {
|
|||||||
await expect(beforeImg).toBeVisible()
|
await expect(beforeImg).toBeVisible()
|
||||||
await expect(afterImg).toBeVisible()
|
await expect(afterImg).toBeVisible()
|
||||||
|
|
||||||
const handle = node.locator('[role="presentation"]')
|
const handle = node.getByRole('presentation')
|
||||||
await expect(handle).toBeVisible()
|
await expect(handle).toBeVisible()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
await handle.evaluate((el) => (el as HTMLElement).style.left),
|
||||||
|
'Slider should default to 50% before screenshot'
|
||||||
).toBe('50%')
|
).toBe('50%')
|
||||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
await expect
|
||||||
|
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||||
|
.toBeCloseTo(50, 0)
|
||||||
|
|
||||||
|
await waitForImagesLoaded(node)
|
||||||
|
await comfyPage.page.mouse.move(0, 0)
|
||||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slider interaction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Mouse hover moves slider position',
|
||||||
|
{ tag: '@smoke' },
|
||||||
|
async ({ comfyPage }) => {
|
||||||
|
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||||
|
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [beforeUrl],
|
||||||
|
afterImages: [afterUrl]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const handle = node.getByRole('presentation')
|
||||||
|
const beforeImg = node.locator('img[alt="Before image"]')
|
||||||
|
const afterImg = node.locator('img[alt="After image"]')
|
||||||
|
await expect(afterImg).toBeVisible()
|
||||||
|
|
||||||
|
// Left edge: sliderPosition ≈ 5 → clip-path inset right ≈ 95%
|
||||||
|
await moveToPercentage(comfyPage.page, afterImg, 5)
|
||||||
|
await expect
|
||||||
|
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||||
|
.toBeGreaterThan(90)
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||||
|
)
|
||||||
|
.toBeLessThan(10)
|
||||||
|
|
||||||
|
// Right edge: sliderPosition ≈ 95 → clip-path inset right ≈ 5%
|
||||||
|
await moveToPercentage(comfyPage.page, afterImg, 95)
|
||||||
|
await expect
|
||||||
|
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||||
|
.toBeLessThan(10)
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(90)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
test('Slider preserves last position when mouse leaves widget', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||||
|
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [beforeUrl],
|
||||||
|
afterImages: [afterUrl]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const handle = node.getByRole('presentation')
|
||||||
|
const afterImg = node.locator('img[alt="After image"]')
|
||||||
|
await expect(afterImg).toBeVisible()
|
||||||
|
|
||||||
|
await moveToPercentage(comfyPage.page, afterImg, 30)
|
||||||
|
// Wait for Vue to commit the slider update
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||||
|
)
|
||||||
|
.toBeCloseTo(30, 0)
|
||||||
|
const positionWhileInside = parseFloat(
|
||||||
|
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.page.mouse.move(0, 0)
|
||||||
|
|
||||||
|
// Position must not reset to default 50%
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||||
|
)
|
||||||
|
.toBeCloseTo(positionWhileInside, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Slider clamps to 0% at left edge of container', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||||
|
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [beforeUrl],
|
||||||
|
afterImages: [afterUrl]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const handle = node.getByRole('presentation')
|
||||||
|
const afterImg = node.locator('img[alt="After image"]')
|
||||||
|
await expect(afterImg).toBeVisible()
|
||||||
|
|
||||||
|
const box = await afterImg.boundingBox()
|
||||||
|
if (!box) throw new Error('Container not found')
|
||||||
|
|
||||||
|
// Move to the leftmost pixel (elementX = 0 → sliderPosition = 0)
|
||||||
|
await comfyPage.page.mouse.move(box.x, box.y + box.height / 2)
|
||||||
|
await expect
|
||||||
|
.poll(() => handle.evaluate((el) => (el as HTMLElement).style.left))
|
||||||
|
.toBe('0%')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single image modes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Only before image shows without slider when afterImages is empty', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const url = createTestImageDataUrl('Before', '#c00')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [url],
|
||||||
|
afterImages: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await expect(node.locator('img')).toHaveCount(1)
|
||||||
|
await expect(node.getByRole('presentation')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Only after image shows without slider when beforeImages is empty', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const url = createTestImageDataUrl('After', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [],
|
||||||
|
afterImages: [url]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await expect(node.locator('img')).toHaveCount(1)
|
||||||
|
await expect(node.getByRole('presentation')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Batch navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Batch navigation appears when before side has multiple images',
|
||||||
|
{ tag: '@smoke' },
|
||||||
|
async ({ comfyPage }) => {
|
||||||
|
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||||
|
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||||
|
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||||
|
const afterUrl = createTestImageDataUrl('B1', '#888')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [url1, url2, url3],
|
||||||
|
afterImages: [afterUrl]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const beforeBatch = node.getByTestId('before-batch')
|
||||||
|
|
||||||
|
await expect(node.getByTestId('batch-nav')).toBeVisible()
|
||||||
|
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
|
||||||
|
// after-batch renders only when afterBatchCount > 1
|
||||||
|
await expect(node.getByTestId('after-batch')).toBeHidden()
|
||||||
|
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
test('Batch navigation is hidden when both sides have single images', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const url = createTestImageDataUrl('Image', '#c00')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [url],
|
||||||
|
afterImages: [url]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await expect(node.getByTestId('batch-nav')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Navigate forward through before images',
|
||||||
|
{ tag: '@smoke' },
|
||||||
|
async ({ comfyPage }) => {
|
||||||
|
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||||
|
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||||
|
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [url1, url2, url3],
|
||||||
|
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const beforeBatch = node.getByTestId('before-batch')
|
||||||
|
const counter = beforeBatch.getByTestId('batch-counter')
|
||||||
|
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||||
|
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||||
|
|
||||||
|
await nextBtn.click()
|
||||||
|
await expect(counter).toHaveText('2 / 3')
|
||||||
|
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
url2
|
||||||
|
)
|
||||||
|
await expect(prevBtn).toBeEnabled()
|
||||||
|
|
||||||
|
await nextBtn.click()
|
||||||
|
await expect(counter).toHaveText('3 / 3')
|
||||||
|
await expect(nextBtn).toBeDisabled()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
test('Navigate backward through before images', async ({ comfyPage }) => {
|
||||||
|
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||||
|
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||||
|
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [url1, url2, url3],
|
||||||
|
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const beforeBatch = node.getByTestId('before-batch')
|
||||||
|
const counter = beforeBatch.getByTestId('batch-counter')
|
||||||
|
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||||
|
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||||
|
|
||||||
|
await nextBtn.click()
|
||||||
|
await nextBtn.click()
|
||||||
|
await expect(counter).toHaveText('3 / 3')
|
||||||
|
|
||||||
|
await prevBtn.click()
|
||||||
|
await expect(counter).toHaveText('2 / 3')
|
||||||
|
await expect(prevBtn).toBeEnabled()
|
||||||
|
await expect(nextBtn).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Before and after batch navigation are independent', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||||
|
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||||
|
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||||
|
const urlA = createTestImageDataUrl('B1', '#880')
|
||||||
|
const urlB = createTestImageDataUrl('B2', '#008')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [url1, url2, url3],
|
||||||
|
afterImages: [urlA, urlB]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const beforeBatch = node.getByTestId('before-batch')
|
||||||
|
const afterBatch = node.getByTestId('after-batch')
|
||||||
|
|
||||||
|
await beforeBatch.getByTestId('batch-next').click()
|
||||||
|
await afterBatch.getByTestId('batch-next').click()
|
||||||
|
|
||||||
|
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
|
||||||
|
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
|
||||||
|
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
url2
|
||||||
|
)
|
||||||
|
await expect(node.locator('img[alt="After image"]')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
urlB
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Visual regression screenshots
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
for (const { pct, expectedClipMin, expectedClipMax } of [
|
||||||
|
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
|
||||||
|
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
|
||||||
|
]) {
|
||||||
|
test(
|
||||||
|
`Screenshot at ${pct}% slider position`,
|
||||||
|
{ tag: '@screenshot' },
|
||||||
|
async ({ comfyPage }) => {
|
||||||
|
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||||
|
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [beforeUrl],
|
||||||
|
afterImages: [afterUrl]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
const beforeImg = node.locator('img[alt="Before image"]')
|
||||||
|
const afterImg = node.locator('img[alt="After image"]')
|
||||||
|
await waitForImagesLoaded(node)
|
||||||
|
await moveToPercentage(comfyPage.page, afterImg, pct)
|
||||||
|
await expect
|
||||||
|
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||||
|
.toBeGreaterThan(expectedClipMin)
|
||||||
|
await expect
|
||||||
|
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||||
|
.toBeLessThan(expectedClipMax)
|
||||||
|
|
||||||
|
await expect(node).toHaveScreenshot(`image-compare-slider-${pct}.png`)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edge cases
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Widget remains stable with broken image URLs', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: ['https://example.invalid/broken.png'],
|
||||||
|
afterImages: ['https://example.invalid/broken2.png']
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await expect(node.locator('img')).toHaveCount(2)
|
||||||
|
await expect(node.getByRole('presentation')).toBeVisible()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
node.evaluate((el) => {
|
||||||
|
const imgs = el.querySelectorAll('img')
|
||||||
|
let errors = 0
|
||||||
|
imgs.forEach((img) => {
|
||||||
|
if (img.complete && img.naturalWidth === 0 && img.src) errors++
|
||||||
|
})
|
||||||
|
return errors
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Rapid value updates show latest images and reset batch index', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const redUrl = createTestImageDataUrl('Red', '#c00')
|
||||||
|
const green1Url = createTestImageDataUrl('G1', '#0c0')
|
||||||
|
const green2Url = createTestImageDataUrl('G2', '#090')
|
||||||
|
const blueUrl = createTestImageDataUrl('Blue', '#00c')
|
||||||
|
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [redUrl, green1Url],
|
||||||
|
afterImages: [blueUrl]
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await node.getByTestId('before-batch').getByTestId('batch-next').click()
|
||||||
|
await expect(
|
||||||
|
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||||
|
).toHaveText('2 / 2')
|
||||||
|
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [green1Url, green2Url],
|
||||||
|
afterImages: [blueUrl]
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
green1Url
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||||
|
).toHaveText('1 / 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Legacy string value shows single image without slider', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||||
|
await comfyPage.page.evaluate(
|
||||||
|
({ url }) => {
|
||||||
|
const node = window.app!.graph.getNodeById(1)
|
||||||
|
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||||
|
if (widget) {
|
||||||
|
widget.value = url
|
||||||
|
widget.callback?.(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ url }
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await expect(node.locator('img')).toHaveCount(1)
|
||||||
|
await expect(node.getByRole('presentation')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Custom beforeAlt and afterAlt are used as img alt text', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||||
|
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: [beforeUrl],
|
||||||
|
afterImages: [afterUrl],
|
||||||
|
beforeAlt: 'Custom before',
|
||||||
|
afterAlt: 'Custom after'
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await expect(node.locator('img[alt="Custom before"]')).toBeVisible()
|
||||||
|
await expect(node.locator('img[alt="Custom after"]')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Large batch sizes show correct counter', async ({ comfyPage }) => {
|
||||||
|
const images = Array.from({ length: 20 }, (_, i) =>
|
||||||
|
createTestImageDataUrl(String(i + 1), '#c00')
|
||||||
|
)
|
||||||
|
await setImageCompareValue(comfyPage, {
|
||||||
|
beforeImages: images,
|
||||||
|
afterImages: images
|
||||||
|
})
|
||||||
|
|
||||||
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||||
|
await expect(
|
||||||
|
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||||
|
).toHaveText('1 / 20')
|
||||||
|
await expect(
|
||||||
|
node.getByTestId('after-batch').getByTestId('batch-counter')
|
||||||
|
).toHaveText('1 / 20')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -11,8 +11,8 @@ test.describe(
|
|||||||
}) => {
|
}) => {
|
||||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||||
|
|
||||||
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||||
expect(initialCount).toBe(2)
|
|
||||||
|
|
||||||
// Copy the KSampler node (puts data-metadata in clipboard)
|
// Copy the KSampler node (puts data-metadata in clipboard)
|
||||||
const ksamplerNodes =
|
const ksamplerNodes =
|
||||||
@@ -51,8 +51,9 @@ test.describe(
|
|||||||
|
|
||||||
// Node count should remain the same — stale node metadata should NOT
|
// Node count should remain the same — stale node metadata should NOT
|
||||||
// be deserialized when a media node is selected.
|
// be deserialized when a media node is selected.
|
||||||
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
|
await expect
|
||||||
expect(finalCount).toBe(initialCount)
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||||
|
.toBe(initialCount)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user