Compare commits

..

7 Commits

Author SHA1 Message Date
Alexander Brown
5b6c3a5455 Merge branch 'main' into test/job-history-e2e 2026-04-10 00:11:39 -07:00
dante01yoon
91205994eb refactor: rename panel to root in JobHistorySidebarTab
Align with the emerging sidebar tab naming convention where all
tab page objects use a `root` getter for container scoping.
2026-04-09 16:16:26 +09:00
dante01yoon
754cea7b72 Merge origin/main into test/job-history-e2e 2026-04-07 11:05:57 +09:00
dante01yoon
725e4eabb3 fix(test): enable QPOV2 setting so job history sidebar tab is registered
The job history sidebar tab is only rendered when Comfy.Queue.QPOV2 is
true (default: false). All 11 tests timed out waiting for the tab button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:47:04 +09:00
dante01yoon
7e3fb71284 fix(test): use seconds-based timestamps to match main convention
Align createMockJob defaults and jobHistory test data with main's
seconds-based timestamp convention (Date.now() / 1000).
2026-04-01 15:00:42 +09:00
dante01yoon
58dcfbc983 fix(test): scope job history locators and use millisecond timestamps
- Scope all JobHistorySidebarTab locators to .sidebar-content-container
  to avoid collision with QueueOverlayExpanded's identical controls
- Use milliseconds (Date.now()) instead of seconds for mock timestamps,
  matching the Python backend's int(time.time() * 1000) format
2026-04-01 14:59:27 +09:00
dante01yoon
1a01192140 test(jobHistory): add E2E tests for job history sidebar tab
Add JobHistorySidebarTab fixture with filter tab, search, and job
item locators. Add createMockJob factory to AssetsHelper. Add 11
test scenarios covering tab display, filter tabs (All/Completed/
Failed), search, empty state, and Failed tab visibility.
2026-04-01 14:59:27 +09:00
243 changed files with 4609 additions and 8753 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,4 @@
# 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.
# Description: Linting and code formatting validation for pull requests
name: 'CI: Lint Format'
on:
@@ -29,6 +26,14 @@ jobs:
- name: 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
run: pnpm lint:fix
@@ -72,8 +77,16 @@ jobs:
echo "See CONTRIBUTING.md for more details."
exit 1
- name: Verify lint and format
uses: ./.github/actions/lint-format-verify
- name: Final validation
run: |
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
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository

View File

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

View File

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

View File

@@ -84,7 +84,6 @@
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",
@@ -117,60 +116,13 @@
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],
"rules": {
"typescript/no-explicit-any": "error",
"no-async-promise-executor": "error",
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "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"
"unicorn/no-empty-file": "error"
}
}
]

View File

@@ -318,9 +318,6 @@ When referencing Comfy-Org repos:
- 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
- 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,39 +40,6 @@ browser_tests/
- **`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.
## 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
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import {
AssetsSidebarTab,
JobHistorySidebarTab,
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
@@ -64,6 +65,7 @@ class ComfyPropertiesPanel {
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _jobHistoryTab: JobHistorySidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _nodeLibraryTabV2: NodeLibrarySidebarTabV2 | null = null
@@ -73,13 +75,20 @@ class ComfyMenu {
public readonly sideToolbar: Locator
public readonly propertiesPanel: ComfyPropertiesPanel
public readonly modeToggleButton: Locator
public readonly buttons: Locator
constructor(public readonly page: Page) {
this.sideToolbar = page.getByTestId(TestIds.sidebar.toolbar)
this.modeToggleButton = page.getByTestId(TestIds.sidebar.modeToggle)
this.propertiesPanel = new ComfyPropertiesPanel(page)
this.buttons = this.sideToolbar.locator('.side-bar-button')
}
get buttons() {
return this.sideToolbar.locator('.side-bar-button')
}
get jobHistoryTab() {
this._jobHistoryTab ??= new JobHistorySidebarTab(this.page)
return this._jobHistoryTab
}
get modelLibraryTab() {
@@ -181,7 +190,6 @@ export class ComfyPage {
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -224,7 +232,6 @@ export class ComfyPage {
this.workflow = new WorkflowHelper(this)
this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page)
this.visibleToasts = this.toast.visibleToasts
this.dragDrop = new DragDropHelper(page)
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
@@ -237,6 +244,10 @@ export class ComfyPage {
this.cloudAuth = new CloudAuthHelper(page)
}
get visibleToasts() {
return this.toast.visibleToasts
}
async setupUser(username: string) {
const res = await this.request.get(`${this.url}/api/users`)
if (res.status() !== 200)
@@ -321,7 +332,7 @@ export class ComfyPage {
// window.app.extensionManager => GraphView ready
window.app && window.app.extensionManager
)
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
await this.nextFrame()
}
@@ -371,7 +382,7 @@ export class ComfyPage {
}
async closeMenu() {
await this.page.locator('button.comfy-close-menu-btn').click()
await this.page.click('button.comfy-close-menu-btn')
await this.nextFrame()
}

View File

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

View File

@@ -7,20 +7,13 @@ import { TestIds } from '@e2e/fixtures/selectors'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export class VueNodeHelpers {
constructor(private page: Page) {}
/**
* Get locator for all Vue node components in the DOM
*/
public readonly nodes: Locator
/**
* 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'
)
get nodes(): Locator {
return this.page.locator('[data-node-id]')
}
/**
@@ -30,6 +23,13 @@ export class VueNodeHelpers {
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).
* Matches against the actual title element, not the full node body.
@@ -37,7 +37,7 @@ export class VueNodeHelpers {
*/
getNodeByTitle(title: string): Locator {
return this.page.locator('[data-node-id]').filter({
has: this.page.getByTestId('node-title').filter({ hasText: title })
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
})
}
@@ -146,7 +146,7 @@ export class VueNodeHelpers {
expectedCount
)
} else {
await this.page.locator('[data-node-id]').first().waitFor()
await this.page.waitForSelector('[data-node-id]')
}
}

View File

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

View File

@@ -2,14 +2,18 @@ import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly menuItems: Locator
constructor(public readonly page: Page) {}
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
get primeVueMenu() {
return this.page.locator('.p-contextmenu, .p-menu')
}
get litegraphMenu() {
return this.page.locator('.litemenu')
}
get menuItems() {
return this.page.locator('.p-menuitem, .litemenu-entry')
}
async clickMenuItem(name: string): Promise<void> {

View File

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

View File

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

View File

@@ -5,16 +5,18 @@ import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator
constructor(
public readonly page: Page,
public readonly tabId: string
) {
this.tabButton = page.locator(`.${tabId}-tab-button`)
this.selectedTabButton = page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
)
}
@@ -30,22 +32,42 @@ class SidebarTab {
}
await this.tabButton.click()
}
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const button of await closeButtons.all()) {
await button.click({ force: true }).catch(() => {})
}
await expect(this.page.locator('.p-toast-message'))
.toHaveCount(0, { timeout: 5000 })
.catch(() => {})
}
}
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) {
super(page, 'node-library')
this.nodeLibrarySearchBoxInput = page.getByPlaceholder('Search Nodes...')
this.nodeLibraryTree = page.getByTestId(TestIds.sidebar.nodeLibrary)
this.nodePreview = page.locator('.node-lib-node-preview')
this.tabContainer = page.locator('.sidebar-content-container')
this.newFolderButton = this.tabContainer.locator('.new-folder-button')
}
get nodeLibrarySearchBoxInput() {
return this.page.getByPlaceholder('Search Nodes...')
}
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() {
@@ -90,25 +112,34 @@ export class NodeLibrarySidebarTab 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) {
super(page, 'node-library')
this.searchInput = page.getByPlaceholder('Search...')
this.sidebarContent = page.locator('.sidebar-content-container')
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
}
get searchInput() {
return this.page.getByPlaceholder('Search...')
}
get sidebarContent() {
return this.page.locator('.sidebar-content-container')
}
getTab(name: string) {
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) {
return this.sidebarContent
.getByRole('treeitem', { name: folderName })
@@ -134,15 +165,12 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
}
export class WorkflowsSidebarTab extends SidebarTab {
public readonly root: Locator
public readonly activeWorkflowLabel: Locator
constructor(public override readonly page: Page) {
super(page, 'workflows')
this.root = page.getByTestId(TestIds.sidebar.workflows)
this.activeWorkflowLabel = this.root.locator(
'.comfyui-workflows-open .p-tree-node-selected .node-label'
)
}
get root() {
return this.page.getByTestId(TestIds.sidebar.workflows)
}
async getOpenedWorkflowNames() {
@@ -151,6 +179,12 @@ export class WorkflowsSidebarTab extends SidebarTab {
.allInnerTexts()
}
get activeWorkflowLabel(): Locator {
return this.root.locator(
'.comfyui-workflows-open .p-tree-node-selected .node-label'
)
}
async getActiveWorkflowName() {
return await this.activeWorkflowLabel.innerText()
}
@@ -204,28 +238,99 @@ export class WorkflowsSidebarTab 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
export class JobHistorySidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'job-history')
}
/** Scope all locators to the sidebar root to avoid collision
* with QueueOverlayExpanded which renders the same controls. */
get root() {
return this.page.locator('.sidebar-content-container')
}
get allTab() {
return this.root.getByRole('button', { name: 'All', exact: true })
}
get completedTab() {
return this.root.getByRole('button', { name: 'Completed', exact: true })
}
get failedTab() {
return this.root.getByRole('button', { name: 'Failed', exact: true })
}
get searchInput() {
return this.root.getByPlaceholder('Search...')
}
get filterButton() {
return this.root.getByRole('button', { name: /Filter/i })
}
get sortButton() {
return this.root.getByRole('button', { name: /Sort/i })
}
get jobItems() {
return this.root.locator('[data-job-id]')
}
get noActiveJobsText() {
return this.root.getByText('No active jobs')
}
async waitForJobsLoad() {
await expect(this.jobItems.first()).toBeVisible({ timeout: 5000 })
}
getJobById(id: string) {
return this.root.locator(`[data-job-id="${id}"]`)
}
get groupLabels() {
return this.root.locator('.text-xs.text-text-secondary')
}
override async open() {
await this.dismissToasts()
await super.open()
await this.allTab.waitFor({ state: 'visible', timeout: 5000 })
}
}
export class ModelLibrarySidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'model-library')
this.searchInput = page.getByPlaceholder('Search Models...')
this.modelTree = page.locator('.model-lib-tree-explorer')
this.refreshButton = page.getByRole('button', { name: 'Refresh' })
this.loadAllFoldersButton = page.getByRole('button', {
name: 'Load All Folders'
})
this.folderNodes = this.modelTree.locator(
'.p-tree-node:not(.p-tree-node-leaf)'
)
this.leafNodes = this.modelTree.locator('.p-tree-node-leaf')
this.modelPreview = page.locator('.model-lib-model-preview')
}
get searchInput() {
return this.page.getByPlaceholder('Search Models...')
}
get modelTree() {
return this.page.locator('.model-lib-tree-explorer')
}
get refreshButton() {
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() {
@@ -249,97 +354,137 @@ export class ModelLibrarySidebarTab extends SidebarTab {
}
export class AssetsSidebarTab extends SidebarTab {
// --- Tab navigation ---
public readonly generatedTab: Locator
public readonly importedTab: Locator
// --- Empty state ---
public readonly emptyStateMessage: Locator
// --- Search & filter ---
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(
}
// --- Tab navigation ---
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
get importedTab() {
return this.page.getByRole('tab', { name: 'Imported' })
}
// --- Empty state ---
get emptyStateMessage() {
return this.page.getByText(
'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) {
return this.page.getByText(title)
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
// --- 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) {
return this.page.locator('[role="button"][data-selected]', {
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) {
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() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
@@ -347,28 +492,20 @@ export class AssetsSidebarTab extends SidebarTab {
await this.generatedTab.waitFor({ state: 'visible' })
}
/** Dismiss all visible toast notifications by clicking their close buttons. */
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click().catch(() => {})
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))
.toHaveCount(0)
.catch(() => {})
}
async switchToImported() {
await this.dismissToasts()
await this.importedTab.click()
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async switchToGenerated() {
await this.dismissToasts()
await this.generatedTab.click()
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true')
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async openSettingsMenu() {
@@ -391,7 +528,7 @@ export class AssetsSidebarTab extends SidebarTab {
async waitForAssets(count?: number) {
if (count !== undefined) {
await expect(this.assetCards).toHaveCount(count)
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
} else {
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
}

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -15,66 +14,14 @@ export class AppModeHelper {
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
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) {
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
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 {
@@ -146,6 +93,61 @@ export class AppModeHelper {
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.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

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

View File

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

View File

@@ -32,20 +32,7 @@ async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
}
export class BuilderSelectHelper {
/** 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
)
}
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
@@ -56,9 +43,12 @@ export class BuilderSelectHelper {
* @param title The widget title shown in the IoItem.
*/
getInputItemMenu(title: string): Locator {
return this.inputItems
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({
has: this.inputItemTitles.getByText(title, { exact: true })
has: this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
})
.getByTestId(TestIds.builder.widgetActionsMenu)
}
@@ -151,7 +141,6 @@ export class BuilderSelectHelper {
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.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 this.comfyPage.nextFrame()
}
@@ -161,19 +150,38 @@ export class BuilderSelectHelper {
* Useful for asserting "Widget not visible" on disconnected inputs.
*/
getInputItemSubtitle(title: string): Locator {
return this.inputItems
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({
has: this.inputItemTitles.getByText(title, { exact: true })
has: this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
})
.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.
* Items are identified by their 0-based position among visible IoItems.
*/
async dragInputItem(fromIndex: number, toIndex: number) {
await dragByIndex(this.inputItems, fromIndex, toIndex)
const items = this.page.getByTestId(TestIds.builder.ioItem)
await dragByIndex(items, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
@@ -200,7 +208,6 @@ export class BuilderSelectHelper {
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
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 this.comfyPage.nextFrame()
}

View File

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

View File

@@ -74,51 +74,6 @@ export class CanvasHelper {
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> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()

View File

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

View File

@@ -8,13 +8,7 @@ import type { Position, Size } from '@e2e/fixtures/types'
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
}
constructor(private comfyPage: ComfyPage) {}
private get page() {
return this.comfyPage.page
@@ -161,6 +155,10 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
get promptDialogInput(): Locator {
return this.page.locator('.p-dialog-content input[type="text"]')
}
async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter')

View File

@@ -157,7 +157,7 @@ export class SubgraphHelper {
// Wait for the appropriate UI element to appear
if (action === 'rightClick') {
await this.page.locator('.litemenu-entry').first().waitFor({
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})

View File

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

View File

@@ -21,10 +21,6 @@ export const TestIds = {
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
closeMinimapButton: 'close-minimap-button',
minimapContainer: 'minimap-container',
minimapCanvas: 'minimap-canvas',
minimapViewport: 'minimap-viewport',
minimapInteractionOverlay: 'minimap-interaction-overlay',
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
@@ -134,24 +130,6 @@ export const TestIds = {
outputPlaceholder: 'builder-output-placeholder',
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: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
@@ -202,7 +180,6 @@ export type TestIdValue =
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,53 @@
import { test as base } from '@playwright/test'
import type { WebSocketRoute } from '@playwright/test'
export const webSocketFixture = base.extend<{
getWebSocket: () => Promise<WebSocketRoute>
ws: { trigger(data: unknown, url?: string): Promise<void> }
}>({
getWebSocket: [
async ({ context }, use) => {
let latest: WebSocketRoute | undefined
let resolve: ((ws: WebSocketRoute) => void) | undefined
await context.routeWebSocket(/\/ws/, (ws) => {
const server = ws.connectToServer()
server.onMessage((message) => {
ws.send(message)
ws: [
async ({ page }, use) => {
// Each time a page loads, to catch navigations
page.on('load', async () => {
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = (window.__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}
})
latest = ws
resolve?.(ws)
})
await use(() => {
if (latest) return Promise.resolve(latest)
return new Promise<WebSocketRoute>((r) => {
resolve = r
})
await use({
async trigger(data, url) {
// Trigger a websocket event on the page
await page.evaluate(
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 }
]
})

View File

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

View File

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

View File

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

View File

@@ -75,7 +75,9 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
]
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
const widgetList = comfyPage.appMode.linearWidgets
@@ -88,7 +90,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
await expect(overlay).toBeVisible()
await expect(overlay).toBeVisible({ timeout: 5000 })
await expect
.poll(() =>
@@ -121,7 +123,9 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
]
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
const widgetList = comfyPage.appMode.linearWidgets
@@ -140,7 +144,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible()
await expect(popover).toBeVisible({ timeout: 5000 })
await expect
.poll(() =>

View File

@@ -16,7 +16,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
})
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.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
})
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 expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
})
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 expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
await expect(comfyPage.appMode.welcome).not.toBeVisible()
})
test('Load template opens template selector', async ({ comfyPage }) => {

View File

@@ -26,7 +26,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await appMode.steps.goToInputs()
const menu = appMode.select.getInputItemMenu('seed')
await expect(menu).toBeVisible()
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
// 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`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(
appMode.linearWidgets.getByText('Builder Input Seed')
).toBeVisible()
@@ -54,7 +54,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
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()
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible()
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.select.renameWidget(menu, 'Preview Seed')
// 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`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
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.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('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`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
})
})

View File

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

View File

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

View File

@@ -10,11 +10,11 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).toBeHidden()
await expect(bottomPanel.root).not.toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeHidden()
await expect(bottomPanel.root).not.toBeVisible()
})
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
@@ -182,7 +182,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeHidden()
await expect(bottomPanel.root).not.toBeVisible()
})
test('should display shortcuts in organized columns', async ({
@@ -192,7 +192,9 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await bottomPanel.keyboardShortcutsButton.click()
await expect(comfyPage.page.getByTestId('shortcuts-columns')).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="shortcuts-columns"]')
).toBeVisible()
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
await expect.poll(() => subcategoryTitles.count()).toBeGreaterThanOrEqual(2)
@@ -203,7 +205,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
}) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).toBeHidden()
await expect(bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Shift+KeyK')

View File

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

View File

@@ -21,7 +21,7 @@ async function reSaveAs(
viewType: 'App' | 'Node graph'
) {
await appMode.footer.openSaveAsFromChevron()
await expect(appMode.saveAs.nameInput).toBeVisible()
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
await appMode.saveAs.fillAndSave(workflowName, viewType)
}
@@ -31,7 +31,7 @@ async function dismissSuccessDialog(
) {
const btn = button === 'close' ? saveAs.closeButton : saveAs.dismissButton
await btn.click()
await expect(saveAs.successDialog).toBeHidden()
await expect(saveAs.successDialog).not.toBeVisible()
}
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
@@ -48,7 +48,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await comfyPage.appMode.footer.saveAsButton.click()
await expect(saveAs.dialog).toBeVisible()
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
await expect(saveAs.nameInput).toBeVisible()
await expect(saveAs.title).toBeVisible()
await expect(saveAs.radioGroup).toBeVisible()
@@ -68,7 +68,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await comfyPage.appMode.footer.saveAsButton.click()
await expect(saveAs.dialog).toBeVisible()
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
await saveAs.nameInput.fill('')
await expect(saveAs.saveButton).toBeDisabled()
})
@@ -78,7 +78,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await setupBuilder(comfyPage)
await comfyPage.appMode.footer.saveAsButton.click()
await expect(saveAs.dialog).toBeVisible()
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
const appRadio = saveAs.viewTypeRadio('App')
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
@@ -113,7 +113,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
})
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
@@ -121,7 +121,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
await comfyPage.appMode.footer.exitBuilder()
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
})
test('Save button directly saves for previously saved workflow', async ({
@@ -136,12 +136,12 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
// Modify the workflow so the save button becomes enabled
await comfyPage.appMode.steps.goToInputs()
await comfyPage.appMode.select.deleteInput('seed')
await expect(footer.saveButton).toBeEnabled()
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
await footer.saveButton.click()
await comfyPage.nextFrame()
await expect(saveAs.dialog).toBeHidden()
await expect(saveAs.dialog).not.toBeVisible({ timeout: 2000 })
await expect(footer.saveButton).toBeDisabled()
})
@@ -156,7 +156,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await footer.openSaveAsFromChevron()
await expect(saveAs.title).toBeVisible()
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
await expect(saveAs.nameInput).toBeVisible()
})
@@ -209,7 +209,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(
comfyPage.page.getByText('Connect an output', { exact: false })
).toBeVisible()
).toBeVisible({ timeout: 5000 })
})
test('save as app produces correct extension and linearMode', async ({
@@ -253,7 +253,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
await comfyPage.appMode.saveAs.viewAppButton.click()
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowActiveAppMode())
@@ -271,9 +271,9 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
)
await comfyPage.appMode.saveAs.exitBuilderButton.click()
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
})
test('save as with different mode does not modify the original workflow', async ({
@@ -291,7 +291,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
// Re-save as node graph — creates a copy
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
await expect(appMode.saveAs.successMessage).toBeVisible()
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
@@ -325,11 +325,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await reSaveAs(appMode, name, 'App')
await expect(appMode.saveAs.overwriteDialog).toBeVisible()
await expect(appMode.saveAs.overwriteDialog).toBeVisible({ timeout: 5000 })
await appMode.saveAs.overwriteButton.click()
await expect(appMode.saveAs.overwriteDialog).toBeHidden()
await expect(appMode.saveAs.overwriteDialog).not.toBeVisible()
await expect(appMode.saveAs.successMessage).toBeVisible()
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
@@ -351,7 +351,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await dismissSuccessDialog(appMode.saveAs)
await reSaveAs(appMode, name, 'Node graph')
await expect(appMode.saveAs.successMessage).toBeVisible()
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())

View File

@@ -70,7 +70,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await expect(menu).toBeVisible()
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).toBeHidden()
await expect(menu).not.toBeVisible()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
@@ -81,7 +81,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await expect(menu).toBeVisible()
await handItem.click()
await comfyPage.nextFrame()
await expect(menu).toBeHidden()
await expect(menu).not.toBeVisible()
})
test('closes when Escape is pressed', async ({ comfyPage }) => {
@@ -91,7 +91,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await expect(menu).toBeVisible()
await selectItem.press('Escape')
await comfyPage.nextFrame()
await expect(menu).toBeHidden()
await expect(menu).not.toBeVisible()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
})
@@ -197,7 +197,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await selectItem.press('ArrowDown')
await handItem.press('Escape')
await comfyPage.nextFrame()
await expect(menu).toBeHidden()
await expect(menu).not.toBeVisible()
await expect(trigger).toBeFocused()
})
})

View File

@@ -206,8 +206,8 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
// Ensure undo reverts both changes
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
await expect(node).not.toBeBypassed({ timeout: 5000 })
await expect(node).not.toBeCollapsed({ timeout: 5000 })
await waitForChangeTrackerSettled(comfyPage, {
isModified: false,
redoQueueSize: 1,

View File

@@ -75,18 +75,15 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
})
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 }
)
.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)
})
.toBe(CLOUD_ASSETS[0].name)
})
})

View File

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

View File

@@ -38,13 +38,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).toBeHidden()
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await expect(selectedButton).toBeHidden()
await expect(selectedButton).not.toBeVisible()
})
}
})
@@ -172,7 +172,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
@@ -189,7 +189,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await expect(minimap).toBeHidden()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await expect(minimap).toBeVisible()
@@ -198,13 +198,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).toBeHidden()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await expect(comfyPage.bottomPanel.root).toBeHidden()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
@@ -250,7 +250,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
@@ -303,7 +303,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})

View File

@@ -11,7 +11,9 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
@@ -24,16 +26,17 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.getByTestId('settings-dialog')
const settingsLocator = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).toBeHidden()
await expect(settingsLocator).not.toBeVisible()
})
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const maxSpeed = 2.5
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await test.step('Setting should persist', async () => {
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed'))
@@ -46,7 +49,9 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.press('Control+,')
// Open the keybinding tab
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })

View File

@@ -307,7 +307,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
await searchInput.fill('Test Pack B')
await expect(dialog.getByText('Test Pack B')).toBeVisible()
await expect(dialog.getByText('Test Pack A')).toBeHidden()
await expect(dialog.getByText('Test Pack A')).not.toBeVisible()
})
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
@@ -360,7 +360,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
await expect(dialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
await expect(dialog).not.toBeVisible()
})
test('Empty search shows no results message', async ({ comfyPage }) => {

View File

@@ -60,7 +60,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
})
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeHidden()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
@@ -83,7 +83,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
})
await dialog.getByLabel('Close').click()
await expect(dialog).toBeHidden()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
@@ -106,7 +106,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog).toBeHidden()
await expect(dialog).not.toBeVisible()
})
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
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeHidden()
await expect(dialog).not.toBeVisible()
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog).toBeVisible()

View File

@@ -61,7 +61,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
await expect(dialog.root).toBeVisible()
await dialog.close()
await expect(dialog.root).toBeHidden()
await expect(dialog.root).not.toBeVisible()
})
test('Escape key closes dialog', async ({ comfyPage }) => {
@@ -70,7 +70,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
await expect(dialog.root).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).toBeHidden()
await expect(dialog.root).not.toBeVisible()
})
test('Search filters settings list', async ({ comfyPage }) => {
@@ -153,7 +153,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const expanded = await select.getAttribute('aria-expanded')
if (expanded !== 'true') await select.click()
await expect(select).toHaveAttribute('aria-expanded', 'true')
}).toPass({ timeout: 5000 })
}).toPass({ timeout: 3000 })
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'

View File

@@ -10,7 +10,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).toBeHidden()
await expect(textareaWidget).not.toBeVisible()
})
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {
@@ -25,8 +25,8 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
for (const node of nodes) {
await node.click('collapse')
}
await expect(firstMultiline).toBeHidden()
await expect(lastMultiline).toBeHidden()
await expect(firstMultiline).not.toBeVisible()
await expect(lastMultiline).not.toBeVisible()
})
test(
@@ -35,7 +35,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await expect(comfyPage.menu.sideToolbar).toBeHidden()
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
}
)

View File

@@ -74,7 +74,7 @@ test.describe('Error dialog', () => {
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
await expect(errorDialog.locator('pre')).toBeHidden()
await expect(errorDialog.locator('pre')).not.toBeVisible()
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
@@ -83,7 +83,7 @@ test.describe('Error dialog', () => {
await expect(reportPre).toHaveText(/\S/)
await expect(
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
).toBeHidden()
).not.toBeVisible()
})
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({

View File

@@ -100,7 +100,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
@@ -112,10 +112,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
})
@@ -156,7 +156,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(overlay).not.toBeVisible()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -168,7 +168,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
@@ -181,8 +181,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
@@ -193,7 +195,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).toBeHidden()
await expect(overlay).not.toBeVisible()
})
})
})

View File

@@ -63,13 +63,19 @@ test.describe(
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.poll(async () => (await input.getWidget(0)).getValue(), {
timeout: 2_000
})
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.poll(async () => (await output1.getWidget(0)).getValue(), {
timeout: 2_000
})
.toBe('foo')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.poll(async () => (await output2.getWidget(0)).getValue(), {
timeout: 2_000
})
.toBe('')
})
}

View File

@@ -69,7 +69,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(flags?.data).not.toBeNull()
expect(flags?.data).toHaveProperty('supports_preview_metadata')
expect(typeof flags?.data?.supports_preview_metadata).toBe('boolean')
}).toPass({ timeout: 5000 })
}).toPass()
// Verify server sent feature flags back
await expect(async () => {
@@ -82,7 +82,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(flags).toHaveProperty('max_upload_size')
expect(typeof flags?.max_upload_size).toBe('number')
expect(Object.keys(flags ?? {}).length).toBeGreaterThan(0)
}).toPass({ timeout: 5000 })
}).toPass()
await newPage.close()
})
@@ -102,7 +102,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
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 })
}).toPass()
})
test('serverSupportsFeature method works with real backend flags', async ({
@@ -182,7 +182,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
)
expect(typeof maxUpload).toBe('number')
expect(maxUpload as number).toBeGreaterThan(0)
}).toPass({ timeout: 5000 })
}).toPass()
// Test getServerFeature with default value for non-existent feature
await expect
@@ -210,7 +210,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(typeof features.supports_preview_metadata).toBe('boolean')
expect(features).toHaveProperty('max_upload_size')
expect(Object.keys(features).length).toBeGreaterThan(0)
}).toPass({ timeout: 5000 })
}).toPass()
})
test('Client feature flags are immutable', async ({ comfyPage }) => {
@@ -348,14 +348,14 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(flags).toHaveProperty('supports_preview_metadata')
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
expect(flags).toHaveProperty('max_upload_size')
}).toPass({ timeout: 5000 })
}).toPass()
// Verify feature flags were received and API was initialized
await expect(async () => {
const readiness = await newPage.evaluate(() => window.__appReadiness)
expect(readiness?.featureFlagsReceived).toBe(true)
expect(readiness?.apiInitialized).toBe(true)
}).toPass({ timeout: 5000 })
}).toPass()
await newPage.close()
})

View File

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

View File

@@ -109,6 +109,6 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
expect(r.switchOutputLinkIds).toEqual(
expect.arrayContaining([r.cfg85LinkId, r.cfg86LinkId])
)
}).toPass({ timeout: 5000 })
}).toPass()
})
})

View File

@@ -94,6 +94,6 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
await backdrop.click()
// Modal should be hidden
await expect(zoomModal).toBeHidden()
await expect(zoomModal).not.toBeVisible()
})
})

View File

@@ -88,10 +88,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
await comfyPage.page
.locator('.p-tree-node-label.tree-explorer-node-label')
.first()
.hover()
await comfyPage.page.hover('.p-tree-node-label.tree-explorer-node-label')
await expect(
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
@@ -102,7 +99,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
.click()
})
})
test(
'Can be added to canvas using search',
{ tag: '@screenshot' },
@@ -158,7 +154,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.nextFrame()
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
await manage1.close()
await expect(manage1.root).toBeHidden()
await expect(manage1.root).not.toBeVisible()
const manage2 = await group2.manageGroupNode()
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
@@ -245,7 +241,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
).not.toBeVisible()
})
test.describe('Copy and paste', () => {
@@ -353,7 +349,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.canvas.click({

View File

@@ -1,10 +1,9 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Image Compare', { tag: '@widget' }, () => {
test.describe('Image Compare', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
@@ -22,12 +21,7 @@ test.describe('Image Compare', { tag: '@widget' }, () => {
async function setImageCompareValue(
comfyPage: ComfyPage,
value: {
beforeImages: string[]
afterImages: string[]
beforeAlt?: string
afterAlt?: string
}
value: { beforeImages: string[]; afterImages: string[] }
) {
await comfyPage.page.evaluate(
({ value }) => {
@@ -43,48 +37,6 @@ test.describe('Image Compare', { tag: '@widget' }, () => {
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(
'Shows empty state when no images are set',
{ tag: '@smoke' },
@@ -94,14 +46,10 @@ test.describe('Image Compare', { tag: '@widget' }, () => {
await expect(node).toContainText('No images to compare')
await expect(node.locator('img')).toHaveCount(0)
await expect(node.getByRole('presentation')).toHaveCount(0)
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
}
)
// ---------------------------------------------------------------------------
// Slider defaults
// ---------------------------------------------------------------------------
test(
'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] },
@@ -119,444 +67,15 @@ test.describe('Image Compare', { tag: '@widget' }, () => {
await expect(beforeImg).toBeVisible()
await expect(afterImg).toBeVisible()
const handle = node.getByRole('presentation')
const handle = node.locator('[role="presentation"]')
await expect(handle).toBeVisible()
expect(
await handle.evaluate((el) => (el as HTMLElement).style.left),
'Slider should default to 50% before screenshot'
await handle.evaluate((el) => (el as HTMLElement).style.left)
).toBe('50%')
await expect
.poll(() => getClipPathInsetRightPercent(beforeImg))
.toBeCloseTo(50, 0)
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
await waitForImagesLoaded(node)
await comfyPage.page.mouse.move(0, 0)
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.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -180,48 +180,6 @@ test.describe('Node Interaction', () => {
})
})
test.describe('Node Duplication', () => {
test.beforeEach(async ({ comfyPage }) => {
// Pin this suite to the legacy canvas path so Alt+drag exercises
// LGraphCanvas, not the Vue node drag handler.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
})
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
const before = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
before,
'Expected exactly 2 CLIPTextEncode nodes in default graph'
).toHaveLength(2)
const target = before[0]
const pos = await target.getPosition()
const src = { x: pos.x + 16, y: pos.y + 16 }
await comfyPage.page.mouse.move(src.x, src.y)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(src.x + 120, src.y + 80, { steps: 20 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')).length
)
.toBe(3)
expect(await target.exists()).toBe(true)
})
})
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -393,20 +351,16 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
// Wait for the double-click window (300ms) to expire so the next
// click at the same position isn't interpreted as a double-click.
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const pointer = window.app!.canvas.pointer
if (!pointer.eLastDown) return true
return performance.now() - pointer.eLastDown.timeStamp > 300
})
)
.toBe(true)
await comfyPage.canvas.click({
position: togglerPos
})
// Re-expand: clicking the canvas toggler on a collapsed node is
// unreliable because DOM widget overlays may intercept the pointer
// event. Use programmatic collapse() for the expand step.
// TODO(#11006): Restore click-to-expand once DOM widget overlay pointer interception is fixed
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)!
node.collapse()
window.app!.canvas.setDirty(true, true)
}, targetNode.id)
await comfyPage.nextFrame()
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
// Move mouse away to avoid hover highlight differences.
await comfyPage.canvasOps.moveMouseToEmptyArea()
@@ -1294,7 +1248,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Space + left-click drag should pan canvas', async ({ comfyPage }) => {
// Click canvas to focus it
await comfyPage.canvas.click()
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.down('Space')
@@ -1363,7 +1317,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
'panning'
)
await comfyPage.canvas.click()
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')

View File

@@ -25,7 +25,7 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.getByTestId('docked-job-history-action')
comfyPage.page.locator('[data-testid="docked-job-history-action"]')
).toBeVisible()
})
@@ -34,7 +34,9 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
}) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.getByTestId('docked-job-history-action')
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
await expect(action).toBeVisible()
await expect(action).not.toBeEmpty()
})
@@ -43,7 +45,7 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.getByTestId('show-run-progress-bar-action')
comfyPage.page.locator('[data-testid="show-run-progress-bar-action"]')
).toBeVisible()
})
@@ -51,18 +53,20 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.getByTestId('clear-history-action')
comfyPage.page.locator('[data-testid="clear-history-action"]')
).toBeVisible()
})
test('Clicking docked job history closes popover', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.getByTestId('docked-job-history-action')
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
await expect(action).toBeVisible()
await action.click()
await expect(action).toBeHidden()
await expect(action).not.toBeVisible()
})
test('Clicking show run progress bar toggles setting', async ({
@@ -74,7 +78,9 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.getByTestId('show-run-progress-bar-action')
const action = comfyPage.page.locator(
'[data-testid="show-run-progress-bar-action"]'
)
await action.click()
await expect

View File

@@ -79,7 +79,9 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss, then close settings via Escape
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
@@ -131,7 +133,9 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Export via ellipsis menu
await menuButton.click()
@@ -179,7 +183,9 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Delete via ellipsis menu
await menuButton.click()
@@ -217,7 +223,9 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Save as new preset via ellipsis menu
await menuButton.click()
@@ -229,7 +237,9 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
await promptInput.press('Enter')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Verify preset trigger shows my-custom-preset
await expect(presetTrigger).toContainText('my-custom-preset')

View File

@@ -14,38 +14,48 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible({ timeout: 5000 })
})
test('Workflow info section visible', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.getByTestId('linear-workflow-info')
).toBeVisible()
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
).toBeVisible({ timeout: 5000 })
})
test('Returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeHidden()
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).not.toBeVisible()
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(comfyPage.canvas).toBeHidden()
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await expect(comfyPage.canvas).not.toBeVisible()
})
})

View File

@@ -25,6 +25,6 @@ export class Load3DViewerHelper {
}
async waitForClosed(): Promise<void> {
await expect(this.dialog).toBeHidden()
await expect(this.dialog).toBeHidden({ timeout: 5000 })
}
}

View File

@@ -66,14 +66,16 @@ test.describe('Load3D', () => {
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const config = n?.properties?.['Scene Config'] as
| Record<string, string>
| undefined
return config?.backgroundColor
})
.poll(
() =>
comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const config = n?.properties?.['Scene Config'] as
| Record<string, string>
| undefined
return config?.backgroundColor
}),
{ timeout: 3000 }
)
.toBe('#cc3333')
@@ -109,7 +111,9 @@ test.describe('Load3D', () => {
const node = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await node.getWidget(0)
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await expect
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
@@ -139,7 +143,9 @@ test.describe('Load3D', () => {
const node = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await node.getWidget(0)
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await expect
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()

View File

@@ -18,7 +18,9 @@ test.describe('Load3D Viewer', () => {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await nodeRef.getWidget(0)
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await expect
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
.toContain('cube.obj')
await load3d.waitForModelLoaded()
})

View File

@@ -101,7 +101,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
// Check initial state of bottom panel (it's initially hidden)
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).toBeHidden()
await expect(bottomPanel.root).not.toBeVisible()
// Checkmark should be invisible initially (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
@@ -126,7 +126,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is hidden again
await expect(bottomPanel.root).toBeHidden()
await expect(bottomPanel.root).not.toBeVisible()
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
@@ -138,7 +138,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
.click({ position: { x: viewport.width - 10, y: 10 } })
// Verify menu is now closed
await expect(menu).toBeHidden()
await expect(menu).not.toBeVisible()
})
test('Displays keybinding next to item', async ({ comfyPage }) => {

View File

@@ -1,38 +1,8 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
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
})
}
async function clickMinimapAt(
overlay: Locator,
page: Page,
relX: number,
relY: number
) {
const box = await overlay.boundingBox()
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
// Click area — avoiding the settings button (top-left, 32×32px)
// and close button (top-right, 32×32px)
await page.mouse.click(
box!.x + box!.width * relX,
box!.y + box!.height * relY
)
}
test.describe('Minimap', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -43,20 +13,14 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
const minimapCanvas = minimapContainer.getByTestId(
TestIds.canvas.minimapCanvas
)
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.getByTestId(
TestIds.canvas.minimapViewport
)
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
@@ -76,16 +40,12 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(toggleButton).toBeVisible()
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
@@ -93,32 +53,30 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimapContainer).toBeVisible()
await toggleButton.click()
await expect(minimapContainer).toBeHidden()
await expect(minimapContainer).not.toBeVisible()
await toggleButton.click()
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).toBeHidden()
await expect(minimapContainer).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).toBeVisible()
})
test('Close button hides minimap', async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).toBeHidden()
await expect(minimap).not.toBeVisible()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
@@ -130,9 +88,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
@@ -149,135 +105,14 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
}
)
test('Minimap canvas is non-empty for a workflow with nodes', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await comfyPage.keyboard.selectAll()
await comfyPage.vueNodes.deleteSelected()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
})
test('Clicking minimap corner pans the main canvas', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
await expect(minimap).toBeVisible()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
await expect
.poll(() =>
comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
)
.not.toStrictEqual(before)
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transformBefore)
})
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(minimap).toBeVisible()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 1000
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await comfyPage.page.evaluate(() => {
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
})
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
timeout: 2000
})
.not.toBe(transformBefore)
await comfyPage.nextFrame()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
await comfyPage.nextFrame()
const after = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
const TOLERANCE = 50
expect(
Math.abs(after.x - before.x),
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
expect(
Math.abs(after.y - before.y),
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
})
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const viewport = minimap.locator('.minimap-viewport')
await expect(viewport).toBeVisible()
await expect(async () => {

View File

@@ -37,15 +37,19 @@ test.describe(
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
await expect(menu).toBeVisible({ timeout: 3000 })
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
await comfyPage.nextFrame()
@@ -64,7 +68,8 @@ test.describe(
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
{
message:
'Menu should overflow vertically so this test exercises the viewport clamp'
'Menu should overflow vertically so this test exercises the viewport clamp',
timeout: 3000
}
)
.toBe(true)
@@ -92,7 +97,9 @@ test.describe(
await comfyPage.nextFrame()
// The node should be removed from the graph
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
.toBe(0)
})
}
)

View File

@@ -40,14 +40,13 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
).not.toBeVisible()
// If the node's multiline text widget is visible, then it was loaded successfully
await expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(
1
)
})
test('Old workflow with converted input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/old_workflow_converted_input')
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -63,7 +62,6 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
expect(vaeInput!.link).toBeNull()
expect(convertedInput!.link).not.toBeNull()
})
test('Renamed converted input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/renamed_converted_widget')
const node = await comfyPage.nodeOps.getNodeRefById('3')
@@ -71,12 +69,10 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
const renamedInput = inputs.find((w) => w.name === 'breadth')
expect(renamedInput).toBeUndefined()
})
test('slider', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/simple_slider')
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
})
test('unknown converted widget', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
@@ -85,7 +81,6 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
'missing_nodes_converted_widget.png'
)
})
test('dynamically added input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
await expect(helpButton).toBeVisible()
await helpButton.click()
await helpButton.click({ force: true })
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')
@@ -191,7 +191,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
).toBeVisible()
// Verify help page is no longer visible
await expect(helpPage.locator('.node-help-content')).toBeHidden()
await expect(helpPage.locator('.node-help-content')).not.toBeVisible()
})
})
@@ -505,7 +505,7 @@ This is English documentation.
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
await expect(helpPage.locator('.p-progressspinner')).toBeHidden()
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
// Should show some content even on error
await expect(helpPage).not.toHaveText('')

View File

@@ -203,7 +203,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Escape')
// Verify the filter selection panel is hidden
await expect(panel.header).toBeHidden()
await expect(panel.header).not.toBeVisible()
// Verify the node search dialog is still visible
await expect(comfyPage.searchBox.input).toBeVisible()

View File

@@ -29,7 +29,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect(searchBoxV2.input).not.toBeVisible()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
@@ -48,7 +48,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
// Enter should add the first (selected) result
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect(searchBoxV2.input).not.toBeVisible()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
@@ -141,7 +141,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
// Enter selects and adds node
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect(searchBoxV2.input).not.toBeVisible()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())

View File

@@ -39,7 +39,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await expect(searchBoxV2.input).not.toBeVisible()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
@@ -56,7 +56,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await expect(searchBoxV2.input).not.toBeVisible()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
@@ -104,7 +104,9 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
.click()
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
const filterChip = searchBoxV2.dialog.locator(
'[data-testid="filter-chip"]'
)
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
@@ -115,7 +117,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed
await expect(filterChip).toBeHidden()
await expect(filterChip).not.toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
})
})

View File

@@ -1,413 +0,0 @@
import type { WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { comfyPageFixture, comfyExpect as expect } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import { ExecutionHelper } from '../fixtures/helpers/ExecutionHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
// Node IDs from the default workflow (browser_tests/assets/default.json, 7 nodes)
const SAVE_IMAGE_NODE = '9'
const KSAMPLER_NODE = '3'
const ALL_NODE_IDS = ['4', '6', '7', '5', KSAMPLER_NODE, '8', SAVE_IMAGE_NODE]
/** Queue a prompt, intercept it, and send execution_start. */
async function startExecution(
comfyPage: ComfyPage,
ws: WebSocketRoute,
exec?: ExecutionHelper
) {
exec ??= new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
// Allow storeJob() to complete before sending WS events
await comfyPage.nextFrame()
exec.executionStart(jobId)
return { exec, jobId }
}
function imageOutput(...filenames: string[]) {
return {
images: filenames.map((filename) => ({
filename,
subfolder: '',
type: 'output'
}))
}
}
test.describe('Output History', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await comfyPage.nextFrame()
})
test('Skeleton appears on execution start', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
})
test('Latent preview replaces skeleton', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Image output replaces skeleton on executed', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('test_output.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Multiple outputs from single execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput('output_001.png', 'output_002.png', 'output_003.png')
)
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(3)
})
test('Video output renders video element', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, {
gifs: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
})
await expect(
comfyPage.appMode.outputHistory.videoOutputs.first()
).toBeVisible()
})
test('Cancel button sends interrupt during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const job: RawJobListItem = {
id: jobId,
status: 'in_progress',
create_time: Date.now() / 1000,
priority: 0
}
await comfyPage.page.route(
/\/api\/jobs\?status=in_progress/,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: [job],
pagination: { offset: 0, limit: 200, total: 1, has_more: false }
})
})
},
{ times: 1 }
)
// Trigger queue refresh
exec.status(1)
await comfyPage.nextFrame()
await expect(comfyPage.appMode.cancelRunButton).toBeVisible()
await comfyPage.page.route('**/interrupt', (route) =>
route.fulfill({ status: 200 })
)
const interruptRequest = comfyPage.page.waitForRequest('**/interrupt')
await comfyPage.appMode.cancelRunButton.click()
await interruptRequest
})
test('Full execution lifecycle cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton appears
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
// Latent preview replaces skeleton
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
// Image output replaces latent
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('lifecycle_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
// Job completes with history mock - in-progress items fully resolved
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'lifecycle_out.png')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
// Output now appears as a history item
await expect(
comfyPage.appMode.outputHistory.historyItems.first()
).toBeVisible()
})
test('Auto-selection follows latest in-progress item', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton is auto-selected
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toBeVisible()
// First image is auto-selected
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
// Second image arrives - selection auto-follows without user click
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /second\.png/)
})
test('Clicking item breaks auto-follow during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Send first image
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(1)
// Click the first image to break auto-follow
await comfyPage.appMode.outputHistory.inProgressItems.first().click()
// Send second image - selection should NOT move to it
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(2)
// The first item should still be selected (not auto-followed to second)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toHaveCount(1)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
})
test('Non-output node executed events are filtered', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
// KSampler is not an output node - should be filtered
exec.executed(jobId, KSAMPLER_NODE, imageOutput('ksampler_out.png'))
await comfyPage.nextFrame()
// KSampler output should not create image outputs
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(0)
// Now send from the actual output node (SaveImage)
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('save_image_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
})
test('In-progress items are outside the scrollable area', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
// Complete one execution with 100 image outputs
const { exec, jobId } = await startExecution(comfyPage, ws)
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput(
...Array.from(
{ length: 100 },
(_, i) => `image_${String(i).padStart(3, '0')}.png`
)
)
)
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'image_000.png')
await expect(comfyPage.appMode.outputHistory.historyItems).toHaveCount(100)
// First history item is visible before scrolling
const firstItem = comfyPage.appMode.outputHistory.historyItems.first()
await expect(firstItem).toBeInViewport()
// Scroll the history feed all the way to the right
await comfyPage.appMode.outputHistory.outputs.evaluate((el) => {
el.scrollLeft = el.scrollWidth
})
// First history item is now off-screen
await expect(firstItem).not.toBeInViewport()
// Start a new execution to get an in-progress item
await startExecution(comfyPage, ws, exec)
// In-progress item is visible despite scrolling
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeInViewport()
})
test('Execution error cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executionError(jobId, KSAMPLER_NODE, 'Test error')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
})
test('Progress bars update for both node and overall progress', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const {
inProgressItems,
headerOverallProgress,
headerNodeProgress,
itemOverallProgress,
itemNodeProgress
} = comfyPage.appMode.outputHistory
await expect(inProgressItems.first()).toBeVisible()
// Initially both bars are at 0%
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler starts executing - node progress at 50%
exec.executing(jobId, KSAMPLER_NODE)
exec.progress(jobId, KSAMPLER_NODE, 5, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*50%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*50%/)
// Overall still 0% - no nodes completed yet
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler finishes - overall advances (1 of 7 nodes)
exec.executed(jobId, KSAMPLER_NODE, {})
const oneNodePercent = Math.round((1 / ALL_NODE_IDS.length) * 100)
const pct = new RegExp(`width:\\s*${oneNodePercent}%`)
await expect(headerOverallProgress).toHaveAttribute('style', pct)
await expect(itemOverallProgress).toHaveAttribute('style', pct)
// Node progress reaches 100%
exec.progress(jobId, KSAMPLER_NODE, 10, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*100%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*100%/)
// Complete remaining nodes - overall reaches 100%
const remainingNodes = ALL_NODE_IDS.filter((id) => id !== KSAMPLER_NODE)
for (const nodeId of remainingNodes) {
exec.executing(jobId, nodeId)
exec.executed(jobId, nodeId, {})
}
await expect(headerOverallProgress).toHaveAttribute(
'style',
/width:\s*100%/
)
await expect(itemOverallProgress).toHaveAttribute('style', /width:\s*100%/)
})
})

View File

@@ -1,17 +1,9 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
drawStroke,
hasCanvasContent,
triggerSerialization
} from '@e2e/helpers/painter'
test.describe('Painter', { tag: '@widget' }, () => {
test.describe('Painter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
await comfyPage.vueNodes.waitForNodes()
@@ -28,15 +20,9 @@ test.describe('Painter', { tag: '@widget' }, () => {
await expect(painterWidget).toBeVisible()
await expect(painterWidget.locator('canvas')).toBeVisible()
await expect(
painterWidget.getByRole('button', { name: 'Brush' })
).toBeVisible()
await expect(
painterWidget.getByRole('button', { name: 'Eraser' })
).toBeVisible()
await expect(
painterWidget.getByTestId('painter-clear-button')
).toBeVisible()
await expect(painterWidget.getByText('Brush')).toBeVisible()
await expect(painterWidget.getByText('Eraser')).toBeVisible()
await expect(painterWidget.getByText('Clear')).toBeVisible()
await expect(
painterWidget.locator('input[type="color"]').first()
).toBeVisible()
@@ -53,66 +39,22 @@ test.describe('Painter', { tag: '@widget' }, () => {
const canvas = node.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
expect(await hasCanvasContent(canvas), 'canvas should start empty').toBe(
false
)
await drawStroke(comfyPage.page, canvas)
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should have content after stroke'
})
.poll(async () =>
canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return true
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
})
)
.toBe(true)
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}
)
test.describe('Drawing', () => {
test(
'Eraser removes drawn content',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await drawStroke(comfyPage.page, canvas)
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas must have content before erasing'
})
.toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
}
)
test('Stroke ends cleanly when pointer up fires outside canvas', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
@@ -126,250 +68,29 @@ test.describe('Painter', { tag: '@widget' }, () => {
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await expect
.poll(() => hasCanvasContent(canvas), {
message:
'canvas should have content after stroke with pointer up outside'
})
.poll(async () =>
canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return false
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) return true
}
return false
})
)
.toBe(true)
})
})
test.describe('Tool selection', () => {
test('Tool switching toggles brush-only controls', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
await expect(
painterWidget.getByTestId('painter-hardness-row')
).toBeVisible()
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await expect(
painterWidget.getByTestId('painter-color-row'),
'color row should be hidden in eraser mode'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-hardness-row')
).toBeHidden()
await painterWidget.getByRole('button', { name: 'Brush' }).click()
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
await expect(
painterWidget.getByTestId('painter-hardness-row')
).toBeVisible()
})
})
test.describe('Brush settings', () => {
test('Size slider updates the displayed value', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const sizeRow = painterWidget.getByTestId('painter-size-row')
const sizeSlider = sizeRow.getByRole('slider')
const sizeDisplay = sizeRow.getByTestId('painter-size-value')
await expect(sizeDisplay).toHaveText('20')
await sizeSlider.focus()
for (let i = 0; i < 10; i++) {
await sizeSlider.press('ArrowRight')
}
await expect(sizeDisplay).toHaveText('30')
})
test('Opacity input clamps out-of-range values', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const opacityInput = painterWidget
.getByTestId('painter-color-row')
.locator('input[type="number"]')
await opacityInput.fill('150')
await opacityInput.press('Tab')
await expect(opacityInput).toHaveValue('100')
await opacityInput.fill('-10')
await opacityInput.press('Tab')
await expect(opacityInput).toHaveValue('0')
})
})
test.describe('Canvas size controls', () => {
test('Width and height sliders visible without connected input', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(painterWidget.getByTestId('painter-width-row')).toBeVisible()
await expect(
painterWidget.getByTestId('painter-height-row')
).toBeVisible()
await expect(
painterWidget.getByTestId('painter-dimension-text')
).toBeHidden()
})
test('Width slider resizes the canvas element', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
const widthSlider = painterWidget
.getByTestId('painter-width-row')
.getByRole('slider')
const initialWidth = await canvas.evaluate(
(el: HTMLCanvasElement) => el.width
)
expect(initialWidth, 'canvas should start at default width').toBe(512)
await widthSlider.focus()
await widthSlider.press('ArrowRight')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBe(576)
})
test(
'Resize preserves existing drawing',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
const widthSlider = painterWidget
.getByTestId('painter-width-row')
.getByRole('slider')
await drawStroke(comfyPage.page, canvas)
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas must have content before resize'
})
.toBe(true)
await widthSlider.focus()
await widthSlider.press('ArrowRight')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBe(576)
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await expect(node).toHaveScreenshot('painter-after-resize.png')
}
)
})
test.describe('Clear', () => {
test(
'Clear removes all drawn content',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await drawStroke(comfyPage.page, canvas)
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas must have content before clear'
})
.toBe(true)
const clearButton = painterWidget.getByTestId('painter-clear-button')
await clearButton.dispatchEvent('click')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should be clear after click'
})
.toBe(false)
}
)
})
test.describe('Serialization', () => {
test('Drawing triggers upload on serialization', async ({ comfyPage }) => {
const mockUploadResponse: UploadImageResponse = {
name: 'painter-test.png'
}
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockUploadResponse)
})
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'should upload exactly once').toBe(1)
})
test('Empty canvas does not upload on serialization', async ({
comfyPage
}) => {
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockResponse)
})
})
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'empty canvas should not upload').toBe(0)
})
test('Upload failure shows error toast', async ({ comfyPage }) => {
await comfyPage.page.route('**/upload/image', async (route) => {
await route.fulfill({ status: 500 })
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await expect(triggerSerialization(comfyPage.page)).rejects.toThrow()
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
})
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -202,7 +202,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
'domNodes'
])
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

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