Compare commits

..

7 Commits

Author SHA1 Message Date
bymyself
f89f675113 fix: remove duplicate vi.mock calls that broke eviction tests 2026-04-09 23:59:34 -07:00
bymyself
49d2cbd1fa refactor: extract createRafCoalescer utility for last-write-wins RAF batching
Replace manual _pending* variables + createRafBatch + null-check
callbacks with a typed createRafCoalescer<T> that encapsulates the
coalescing pattern. Extract cancelPendingProgressUpdates() helper
to deduplicate the 3 cancel sites in executionStore.
2026-04-09 23:59:34 -07:00
GitHub Action
d4b2264091 [automated] Apply ESLint and Oxfmt fixes 2026-04-09 23:59:34 -07:00
bymyself
96da215bb8 test: cover pending RAF discard when execution completes
Adds tests verifying that pending progress and progress_state RAFs
are cancelled when execution ends via success, error, or interruption.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923353907
2026-04-09 23:59:34 -07:00
bymyself
483d14d120 fix: cancel pending RAFs in resetExecutionState and handleExecuting
Prevents a race condition where:
1. A progress WebSocket event arrives and schedules a RAF
2. An execution-end event fires and clears all state
3. The pending RAF callback fires and writes stale data back

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923324838
2026-04-09 23:59:34 -07:00
bymyself
eadb86122a fix: use createRafBatch utility instead of manual RAF management
Replaces four raw `let` variables and manual requestAnimationFrame/
cancelAnimationFrame calls with two `createRafBatch` instances from
the existing `src/utils/rafBatch.ts` utility.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923338919
2026-04-09 23:59:34 -07:00
bymyself
5a35be7d7a fix: RAF-batch WebSocket progress events to reduce reactive update storms
Amp-Thread-ID: https://ampcode.com/threads/T-019ca43d-b7a5-759f-b88f-1319faac8a01
2026-04-09 23:59:34 -07:00
80 changed files with 2220 additions and 1669 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

@@ -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

@@ -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

@@ -73,13 +73,15 @@ 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 modelLibraryTab() {
@@ -181,7 +183,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 +225,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 +237,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)

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.

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,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,10 +36,22 @@ 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'

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`
)
}
@@ -33,19 +35,28 @@ class SidebarTab {
}
export class NodeLibrarySidebarTab extends SidebarTab {
public readonly nodeLibrarySearchBoxInput: Locator
public readonly nodeLibraryTree: Locator
public readonly nodePreview: Locator
public readonly tabContainer: Locator
public readonly newFolderButton: Locator
constructor(public override readonly page: Page) {
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 +101,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 +154,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 +168,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()
}
@@ -205,27 +228,36 @@ 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
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,95 +281,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.locator('[role="button"][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()
@@ -353,20 +427,24 @@ export class AssetsSidebarTab extends SidebarTab {
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))
.toHaveCount(0)
.toHaveCount(0, { timeout: 5000 })
.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() {
@@ -389,7 +467,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}")`)
@@ -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

@@ -15,26 +15,6 @@ export class AppModeHelper {
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
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
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
@@ -42,31 +22,6 @@ export class AppModeHelper {
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
)
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.locator('[data-testid="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
)
}
private get page(): Page {
@@ -138,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)
}
@@ -160,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()
}

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

@@ -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

@@ -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

@@ -18,7 +18,7 @@ function makeMatcher<T>(
? expect(value, 'Node is ' + type).not
: expect(value, 'Node is not ' + type)
assertion.toBeTruthy()
}).toPass({ timeout: 5000, ...options })
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
@@ -30,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

@@ -4,26 +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.locator('[data-testid="node-title"]')
this.titleInput = locator.locator('[data-testid="node-title-input"]')
this.body = locator.locator('[data-testid^="node-body-"]')
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
this.collapseButton = locator.locator(
'[data-testid="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

@@ -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

@@ -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

@@ -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

@@ -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'
@@ -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)
}
@@ -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')
@@ -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).not.toBeVisible()
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 ({
@@ -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).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

@@ -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

@@ -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

@@ -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

@@ -112,10 +112,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
})

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

@@ -351,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()

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

@@ -16,7 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible()
).toBeVisible({ timeout: 5000 })
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
@@ -24,7 +24,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible()
).toBeVisible({ timeout: 5000 })
})
test('Workflow info section visible', async ({ comfyPage }) => {
@@ -32,7 +32,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
).toBeVisible()
).toBeVisible({ timeout: 5000 })
})
test('Returns to graph mode', async ({ comfyPage }) => {
@@ -40,11 +40,11 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible()
).toBeVisible({ timeout: 5000 })
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).not.toBeVisible()
@@ -55,7 +55,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible()
).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

@@ -37,17 +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.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible()
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()
@@ -66,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)
@@ -94,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

@@ -25,7 +25,10 @@ test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')).length
(await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')).length,
{
timeout: 5000
}
)
.toBe(1)

View File

@@ -45,12 +45,14 @@ 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.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible()
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await comfyPage.page.click('[data-testid="more-options-button"]')
@@ -97,7 +99,9 @@ test.describe(
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
).toBeVisible({
timeout: 5000
})
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
@@ -114,7 +118,7 @@ test.describe(
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Color', { exact: true }).click()
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
await expect(blueSwatch.first()).toBeVisible()
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
await blueSwatch.first().click()
await comfyPage.nextFrame()
@@ -148,7 +152,7 @@ test.describe(
}) => {
await openMoreOptions(comfyPage)
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
await expect(renameItem).toBeVisible()
await expect(renameItem).toBeVisible({ timeout: 5000 })
// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
for (let i = 0; i < 30; i++) {
@@ -171,7 +175,7 @@ test.describe(
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible()
).toBeVisible({ timeout: 5000 })
await comfyPage.page.evaluate(() => {
const btn = document.querySelector(

View File

@@ -187,7 +187,7 @@ test.describe('Assets sidebar - grid view display', () => {
await tab.switchToImported()
// Wait for imported assets to render
await expect(tab.assetCards.first()).toBeVisible()
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
// Imported tab should show the mocked files
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
@@ -242,7 +242,7 @@ test.describe('Assets sidebar - view mode toggle', () => {
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.listViewItems.first()).toBeVisible()
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
})
test('Can switch back to grid view', async ({ comfyPage }) => {
@@ -253,14 +253,14 @@ test.describe('Assets sidebar - view mode toggle', () => {
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible()
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
// Switch back to grid view (settings popover is still open)
await tab.gridViewOption.click()
await tab.waitForAssets()
// Grid cards (with data-selected attribute) should be visible again
await expect(tab.assetCards.first()).toBeVisible()
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
})
})
@@ -299,7 +299,9 @@ test.describe('Assets sidebar - search', () => {
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
await expect
.poll(() => tab.assetCards.count(), { timeout: 5000 })
.toBeLessThan(initialCount)
})
test('Clearing search restores all assets', async ({ comfyPage }) => {
@@ -314,7 +316,7 @@ test.describe('Assets sidebar - search', () => {
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
await tab.searchInput.fill('')
await expect(tab.assetCards).toHaveCount(initialCount)
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
test('Search with no matches shows empty state', async ({ comfyPage }) => {
@@ -323,7 +325,7 @@ test.describe('Assets sidebar - search', () => {
await tab.waitForAssets()
await tab.searchInput.fill('nonexistent_file_xyz')
await expect(tab.assetCards).toHaveCount(0)
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
})
})
@@ -382,7 +384,7 @@ test.describe('Assets sidebar - selection', () => {
await tab.assetCards.first().click()
// Footer should show selection count
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
})
test('Deselect all clears selection', async ({ comfyPage }) => {
@@ -396,7 +398,7 @@ test.describe('Assets sidebar - selection', () => {
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible()
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
// Click "Deselect all"
await tab.deselectAllButton.click()
@@ -447,7 +449,7 @@ test.describe('Assets sidebar - context menu', () => {
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu).toBeVisible({ timeout: 3000 })
})
test('Context menu contains Download action for output asset', async ({
@@ -520,7 +522,7 @@ test.describe('Assets sidebar - context menu', () => {
await tab.assetCards.first().click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu).toBeVisible({ timeout: 3000 })
await expect(
tab.contextMenuItem('Open as workflow in new tab')
@@ -550,8 +552,8 @@ test.describe('Assets sidebar - context menu', () => {
await comfyPage.page.keyboard.up('Control')
// Verify multi-selection took effect and footer is stable before right-clicking
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
// overlay intercepting the event, and assert directly without toPass.
@@ -593,7 +595,7 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.assetCards.first().click()
// Download button in footer should be visible
await expect(tab.downloadSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Footer shows delete button when output assets selected', async ({
@@ -606,7 +608,7 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.assetCards.first().click()
// Delete button in footer should be visible
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Selection count displays correct number', async ({ comfyPage }) => {
@@ -627,7 +629,7 @@ test.describe('Assets sidebar - bulk actions', () => {
await comfyPage.page.keyboard.up('Control')
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
})
})
@@ -746,10 +748,12 @@ test.describe('Assets sidebar - delete confirmation', () => {
await comfyPage.confirmDialog.delete.click()
await expect(dialog).not.toBeVisible()
await expect(tab.assetCards).toHaveCount(initialCount - 1)
await expect(tab.assetCards).toHaveCount(initialCount - 1, {
timeout: 5000
})
const successToast = comfyPage.page.locator('.p-toast-message-success')
await expect(successToast).toBeVisible()
await expect(successToast).toBeVisible({ timeout: 5000 })
})
test('Cancelling delete preserves asset', async ({ comfyPage }) => {

View File

@@ -76,7 +76,9 @@ test.describe('Model library sidebar - folders', () => {
await tab.getFolderByLabel('checkpoints').click()
// Models should appear as leaf nodes
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible({
timeout: 5000
})
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
})
@@ -89,7 +91,9 @@ test.describe('Model library sidebar - folders', () => {
await tab.getFolderByLabel('loras').click()
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible()
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible({
timeout: 5000
})
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
})
})
@@ -115,7 +119,9 @@ test.describe('Model library sidebar - search', () => {
await tab.searchInput.fill('dreamshaper')
// Wait for debounce (300ms) + load + render
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
timeout: 5000
})
// Other models should not be visible
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).not.toBeVisible()
@@ -126,13 +132,17 @@ test.describe('Model library sidebar - search', () => {
await tab.open()
await tab.searchInput.fill('dreamshaper')
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
timeout: 5000
})
// Clear the search
await tab.searchInput.fill('')
// Folders should be visible again (collapsed)
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible({
timeout: 5000
})
await expect(tab.getFolderByLabel('loras')).toBeVisible()
})
@@ -142,7 +152,7 @@ test.describe('Model library sidebar - search', () => {
// Expand a folder and verify models are present before searching
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.leafNodes).not.toHaveCount(0)
await expect(tab.leafNodes).not.toHaveCount(0, { timeout: 5000 })
await tab.searchInput.fill('nonexistent_model_xyz')
@@ -186,7 +196,7 @@ test.describe('Model library sidebar - refresh', () => {
await tab.refreshButton.click()
await refreshRequest
await expect(tab.getFolderByLabel('loras')).toBeVisible()
await expect(tab.getFolderByLabel('loras')).toBeVisible({ timeout: 5000 })
})
test('Load all folders button triggers loading all model data', async ({

View File

@@ -45,7 +45,9 @@ test.describe('Node library sidebar V2', () => {
await expect(tab.getNode('KSampler (Advanced)')).not.toBeVisible()
await tab.searchInput.fill('KSampler')
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
timeout: 5000
})
await expect(tab.getNode('CLIPLoader')).not.toBeVisible()
})
@@ -94,7 +96,7 @@ test.describe('Node library sidebar V2', () => {
const contextMenu = comfyPage.page.getByRole('menuitem', {
name: /Bookmark Node/
})
await expect(contextMenu).toBeVisible()
await expect(contextMenu).toBeVisible({ timeout: 3000 })
})
test('Search clear restores folder view', async ({ comfyPage }) => {
@@ -103,12 +105,14 @@ test.describe('Node library sidebar V2', () => {
await expect(tab.getFolder('sampling')).toBeVisible()
await tab.searchInput.fill('KSampler')
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
timeout: 5000
})
await tab.searchInput.clear()
await tab.searchInput.press('Enter')
await expect(tab.getFolder('sampling')).toBeVisible()
await expect(tab.getFolder('sampling')).toBeVisible({ timeout: 5000 })
})
test('Sort dropdown shows sorting options', async ({ comfyPage }) => {
@@ -118,7 +122,7 @@ test.describe('Node library sidebar V2', () => {
// Reka UI DropdownMenuRadioItem renders with role="menuitemradio"
const options = comfyPage.page.getByRole('menuitemradio')
await expect(options.first()).toBeVisible()
await expect(options.first()).toBeVisible({ timeout: 3000 })
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
})

View File

@@ -289,7 +289,9 @@ test.describe('Workflows sidebar', () => {
)
await closeButton.click()
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames(), {
timeout: 5000
})
.toEqual(['*Unsaved Workflow'])
})
@@ -368,7 +370,7 @@ test.describe('Workflows sidebar', () => {
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1')
await expect(workflowItem).toBeVisible()
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()

View File

@@ -219,7 +219,9 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport))
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 5_000
})
.toBe(true)
})
@@ -235,7 +237,9 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.vueNodes.enterSubgraph('11')
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport))
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 5_000
})
.toBe(true)
})
@@ -259,11 +263,13 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 5_000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
@@ -308,10 +314,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId)
.poll(
() =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId),
{ timeout: 2_000 }
)
.toBeUndefined()
})
@@ -342,18 +350,20 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(node) =>
typeof node.isSubgraphNode === 'function' &&
node.isSubgraphNode()
)
if (!subgraphNode) return { exists: false, progress: null }
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(node) =>
typeof node.isSubgraphNode === 'function' &&
node.isSubgraphNode()
)
if (!subgraphNode) return { exists: false, progress: null }
return { exists: true, progress: subgraphNode.progress }
})
return { exists: true, progress: subgraphNode.progress }
}),
{ timeout: 5_000 }
)
.toEqual({ exists: true, progress: undefined })
})

View File

@@ -15,7 +15,9 @@ async function expectPromotedWidgetNamesToContain(
widgetName: string
) {
await expect
.poll(() => getPromotedWidgetNames(comfyPage, nodeId))
.poll(() => getPromotedWidgetNames(comfyPage, nodeId), {
timeout: 5000
})
.toContain(widgetName)
}
@@ -25,7 +27,9 @@ async function expectPromotedWidgetCountToBeGreaterThan(
count: number
) {
await expect
.poll(() => getPromotedWidgetCount(comfyPage, nodeId))
.poll(() => getPromotedWidgetCount(comfyPage, nodeId), {
timeout: 5000
})
.toBeGreaterThan(count)
}
@@ -284,7 +288,9 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
timeout: 5000
})
.toBeLessThan(initialWidgetCount)
})
})
@@ -326,7 +332,7 @@ test.describe(
.locator('.p-contextmenu')
.locator('text=Promote Widget')
await expect(promoteEntry.first()).toBeVisible()
await expect(promoteEntry.first()).toBeVisible({ timeout: 5000 })
})
})
@@ -530,7 +536,9 @@ test.describe(
expectedNames.splice(removedIndex, 1)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.poll(() => getPromotedWidgetNames(comfyPage, '5'), {
timeout: 5000
})
.toEqual(expectedNames)
})
@@ -545,7 +553,9 @@ test.describe(
let initialWidgetCount = 0
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
timeout: 5000
})
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
@@ -561,7 +571,9 @@ test.describe(
// Widget count should be reduced
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
timeout: 5000
})
.toBeLessThan(initialWidgetCount)
})
})

View File

@@ -22,11 +22,13 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
}, nodeId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
}),
{ timeout: 5_000 }
)
.toBe(true)
}
@@ -147,7 +149,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
)
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(visibleWidgets).toHaveCount(2)
await expect(visibleWidgets).toHaveCount(2, { timeout: 5_000 })
const parentCount = await visibleWidgets.count()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')

View File

@@ -23,7 +23,7 @@ async function exitSubgraphAndPublish(
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1)
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5_000 })
await comfyPage.toast.closeToasts(1)
}

View File

@@ -106,7 +106,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.setup({ clearStorage: true })
// Expect the templates dialog to be shown
await expect(comfyPage.templates.content).toBeVisible()
await expect(comfyPage.templates.content).toBeVisible({ timeout: 5000 })
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
@@ -305,7 +305,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
).toBeVisible()
).toBeVisible({ timeout: 5000 })
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
@@ -399,7 +399,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
const taggedCard = comfyPage.page.getByTestId(
TestIds.templates.workflowCard('tagged-template')
)
await expect(taggedCard).toBeVisible()
await expect(taggedCard).toBeVisible({ timeout: 5000 })
await expect(taggedCard.getByText('Relight')).toBeVisible()
await expect(taggedCard.getByText('Image Edit')).toBeVisible()

View File

@@ -67,7 +67,7 @@ test.describe('Workflow tabs', () => {
const contextMenu = comfyPage.page.locator(
'[role="menu"][data-state="open"]'
)
await expect(contextMenu).toBeVisible()
await expect(contextMenu).toBeVisible({ timeout: 5000 })
await expect(
contextMenu.getByRole('menuitem', { name: /Close Tab/i }).first()
@@ -89,7 +89,7 @@ test.describe('Workflow tabs', () => {
const contextMenu = comfyPage.page.locator(
'[role="menu"][data-state="open"]'
)
await expect(contextMenu).toBeVisible()
await expect(contextMenu).toBeVisible({ timeout: 5000 })
await contextMenu
.getByRole('menuitem', { name: /Close Tab/i })
@@ -123,7 +123,7 @@ test.describe('Workflow tabs', () => {
// WorkflowTab renders "•" when the workflow has unsaved changes
const activeTab = topbar.getActiveTab()
await expect(activeTab.locator('text=•')).toBeVisible()
await expect(activeTab.locator('text=•')).toBeVisible({ timeout: 5000 })
})
test('Multiple tabs can be created, switched, and closed', async ({

View File

@@ -217,12 +217,14 @@ test.describe('Vue Node Context Menu', () => {
if (!loadImageNode) throw new Error('Load Image node not found')
await expect
.poll(() =>
comfyPage.page.evaluate(
(nodeId) =>
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
loadImageNode.id
)
.poll(
() =>
comfyPage.page.evaluate(
(nodeId) =>
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
loadImageNode.id
),
{ timeout: 5_000 }
)
.toBeGreaterThan(0)
})

View File

@@ -102,7 +102,7 @@ test.describe('Vue Nodes - Delete Key Interaction', () => {
// Node count should remain the same
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(initialNodeCount)
})

View File

@@ -132,7 +132,9 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png')
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
timeout: 2_000
})
.toBeDefined()
})
})
@@ -154,7 +156,9 @@ test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
timeout: 2_000
})
.toBeDefined()
})
})

View File

@@ -134,7 +134,11 @@ test.describe('Workflow Persistence', () => {
await tab.switchToWorkflow('outputs-test')
await comfyPage.workflow.waitForWorkflowIdle()
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
await expect
.poll(() => getNodeOutputImageCount(comfyPage, nodeId), {
timeout: 5_000
})
.toBe(1)
})
test('Loading a new workflow cleanly replaces the previous graph', async ({
@@ -187,7 +191,9 @@ test.describe('Workflow Persistence', () => {
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => getWidgetValueSnapshot(comfyPage))
.poll(() => getWidgetValueSnapshot(comfyPage), {
timeout: 5_000
})
.toEqual(widgetValuesBefore)
})

View File

@@ -30,7 +30,7 @@ test.describe('Workflow Tab Thumbnails', { tag: '@workflow' }, () => {
const popover = comfyPage.page.locator('.workflow-popover-fade')
await expect(popover).toHaveCount(1)
await expect(popover).toBeVisible()
await expect(popover).toBeVisible({ timeout: 500 })
if (name) {
await expect(popover).toContainText(name)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.1",
"version": "1.44.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -114,21 +114,4 @@ describe('DomWidget disabled style', () => {
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
it('disables pointer events when widget is not visible', async () => {
const widgetState = createWidgetState(false)
widgetState.visible = false
const { container } = render(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const root = container.querySelector('.dom-widget') as HTMLElement
expect(root.style.pointerEvents).toBe('none')
})
})

View File

@@ -113,10 +113,7 @@ function composeStyle() {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents:
!widgetState.visible || widgetState.readonly || isDisabled
? 'none'
: 'auto',
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
opacity: isDisabled ? 0.5 : 1
}
}

View File

@@ -1,668 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
vi.mock('@vueuse/core', () => ({
useResizeObserver: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: vi.fn()
}))
vi.mock('@/stores/nodeOutputStore', () => {
const getNodeImageUrls = vi.fn()
return {
useNodeOutputStore: () => ({
getNodeImageUrls,
nodeOutputs: {},
nodePreviewImages: {}
})
}
})
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import type { useImageCrop as UseImageCropFn } from './useImageCrop'
import { useImageCrop } from './useImageCrop'
function createMockNode(
overrides: Partial<Record<string, unknown>> = {}
): LGraphNode {
return {
getInputNode: vi.fn().mockReturnValue(null),
getInputLink: vi.fn(),
...overrides
} as unknown as LGraphNode
}
function createPointerEvent(
type: string,
clientX: number,
clientY: number
): PointerEvent {
const event = new PointerEvent(type, { clientX, clientY })
Object.defineProperty(event, 'target', {
value: {
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn()
}
})
return event
}
function createOptions(modelValue?: Partial<Bounds>) {
const bounds: Bounds = {
x: 0,
y: 0,
width: 512,
height: 512,
...modelValue
}
return {
imageEl: ref<HTMLImageElement | null>(null),
containerEl: ref<HTMLDivElement | null>(null),
modelValue: ref(bounds)
}
}
// Single wrapper component used to trigger onMounted lifecycle
const Wrapper = defineComponent({
props: { run: { type: Function, required: true } },
setup(props) {
props.run()
return () => null
}
})
function mountComposable(
options: ReturnType<typeof createOptions>,
nodeId: NodeId = 1
) {
let result!: ReturnType<typeof UseImageCropFn>
mount(Wrapper, {
props: { run: () => (result = useImageCrop(nodeId, options)) }
})
return result
}
function setup(modelValue?: Partial<Bounds>, nodeId: NodeId = 1) {
const options = createOptions(modelValue)
const result = mountComposable(options, nodeId)
return { ...result, options }
}
function setupWithImage(
naturalWidth: number,
naturalHeight: number,
containerWidth: number,
containerHeight: number,
modelValue?: Partial<Bounds>
) {
const options = createOptions({
x: 0,
y: 0,
width: 100,
height: 100,
...modelValue
})
options.imageEl.value = {
naturalWidth,
naturalHeight
} as HTMLImageElement
options.containerEl.value = {
clientWidth: containerWidth,
clientHeight: containerHeight,
getBoundingClientRect: () => ({
width: containerWidth,
height: containerHeight,
x: 0,
y: 0,
top: 0,
left: 0,
right: containerWidth,
bottom: containerHeight,
toJSON: () => {}
})
} as unknown as HTMLDivElement
const result = mountComposable(options)
result.handleImageLoad()
return result
}
describe('useImageCrop', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(resolveNode).mockReturnValue(undefined)
})
describe('crop computed properties', () => {
it('reads crop dimensions from modelValue', () => {
const { cropX, cropY, cropWidth, cropHeight } = setup({
x: 10,
y: 20,
width: 200,
height: 300
})
expect(cropX.value).toBe(10)
expect(cropY.value).toBe(20)
expect(cropWidth.value).toBe(200)
expect(cropHeight.value).toBe(300)
})
it('writes crop dimensions back to modelValue', () => {
const { cropX, cropY, cropWidth, cropHeight, options } = setup()
cropX.value = 50
cropY.value = 60
cropWidth.value = 100
cropHeight.value = 150
expect(options.modelValue.value).toMatchObject({
x: 50,
y: 60,
width: 100,
height: 150
})
})
it('defaults cropWidth/cropHeight to 512 when modelValue is 0', () => {
const { cropWidth, cropHeight } = setup({ width: 0, height: 0 })
expect(cropWidth.value).toBe(512)
expect(cropHeight.value).toBe(512)
})
})
describe('cropBoxStyle', () => {
it('computes style from crop state and scale factor', () => {
const { cropBoxStyle } = setup({
x: 100,
y: 50,
width: 200,
height: 150
})
// With default scaleFactor=1 and offsets=0, border=2
expect(cropBoxStyle.value).toMatchObject({
left: `${100 * 1 - 2}px`,
top: `${50 * 1 - 2}px`,
width: `${200 * 1}px`,
height: `${150 * 1}px`
})
})
})
describe('selectedRatio', () => {
it('defaults to custom when no ratio is locked', () => {
const { selectedRatio } = setup()
expect(selectedRatio.value).toBe('custom')
})
it('sets lockedRatio when selecting a predefined ratio', () => {
const { selectedRatio, isLockEnabled } = setup()
selectedRatio.value = '16:9'
expect(isLockEnabled.value).toBe(true)
expect(selectedRatio.value).toBe('16:9')
})
it('clears lockedRatio when selecting custom', () => {
const { selectedRatio, isLockEnabled } = setup()
selectedRatio.value = '1:1'
expect(isLockEnabled.value).toBe(true)
selectedRatio.value = 'custom'
expect(isLockEnabled.value).toBe(false)
})
})
describe('isLockEnabled', () => {
it('derives locked ratio from current crop dimensions', () => {
const { isLockEnabled, selectedRatio } = setup({
width: 400,
height: 200
})
isLockEnabled.value = true
// Should compute ratio as 400/200 = 2, not match any preset
expect(selectedRatio.value).toBe('custom')
expect(isLockEnabled.value).toBe(true)
})
it('unlocks when set to false', () => {
const { isLockEnabled, selectedRatio } = setup()
isLockEnabled.value = true
isLockEnabled.value = false
expect(isLockEnabled.value).toBe(false)
expect(selectedRatio.value).toBe('custom')
})
})
describe('applyLockedRatio (via selectedRatio setter)', () => {
it('adjusts height to match 1:1 ratio when image is loaded', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 0,
y: 0,
width: 200,
height: 400
})
result.selectedRatio.value = '1:1'
expect(result.cropWidth.value).toBe(200)
expect(result.cropHeight.value).toBe(200)
})
it('clamps height and adjusts width at naturalHeight boundary', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 0,
y: 900,
width: 200,
height: 100
})
result.selectedRatio.value = '1:1'
// Only 100px remain (1000 - 900), so height=100, width=100
expect(result.cropHeight.value).toBeLessThanOrEqual(100)
expect(result.cropWidth.value).toBe(result.cropHeight.value)
})
})
describe('resizeHandles', () => {
it('returns all 8 handles when ratio is unlocked', () => {
const { resizeHandles } = setup()
expect(resizeHandles.value).toHaveLength(8)
})
it('returns only corner handles when ratio is locked', () => {
const { resizeHandles, isLockEnabled } = setup()
isLockEnabled.value = true
const directions = resizeHandles.value.map((h) => h.direction)
expect(directions).toEqual(['nw', 'ne', 'sw', 'se'])
expect(resizeHandles.value).toHaveLength(4)
})
})
describe('handleImageLoad', () => {
it('sets isLoading to false', () => {
const { isLoading, handleImageLoad } = setup()
isLoading.value = true
handleImageLoad()
expect(isLoading.value).toBe(false)
})
})
describe('handleImageError', () => {
it('sets isLoading to false and clears imageUrl', () => {
const { isLoading, imageUrl, handleImageError } = setup()
isLoading.value = true
imageUrl.value = 'http://example.com/img.png'
handleImageError()
expect(isLoading.value).toBe(false)
expect(imageUrl.value).toBeNull()
})
})
describe('handleDragStart/Move/End', () => {
it('does nothing when imageUrl is null', () => {
const { handleDragStart, cropX } = setup({ x: 100 })
handleDragStart(createPointerEvent('pointerdown', 50, 50))
expect(cropX.value).toBe(100)
})
it('ignores drag move when not dragging', () => {
const { handleDragMove, cropX } = setup({ x: 100 })
handleDragMove(createPointerEvent('pointermove', 200, 200))
expect(cropX.value).toBe(100)
})
it('ignores drag end when not dragging', () => {
const { handleDragEnd } = setup()
handleDragEnd(createPointerEvent('pointerup', 200, 200))
})
it('moves crop box by pointer delta', () => {
// 1000x1000 image in 500x500 container: effectiveScale = 0.5
// pointer delta of 50px -> natural delta of 50/0.5 = 100
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
result.handleDragMove(createPointerEvent('pointermove', 50, 30))
expect(result.cropX.value).toBe(200)
expect(result.cropY.value).toBe(160)
result.handleDragEnd(createPointerEvent('pointerup', 50, 30))
})
it('clamps drag to image boundaries', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 700,
y: 700,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
// delta = 500/0.5 = 1000, so x would be 1700 but max is 800
result.handleDragMove(createPointerEvent('pointermove', 500, 500))
expect(result.cropX.value).toBe(800)
expect(result.cropY.value).toBe(800)
result.handleDragEnd(createPointerEvent('pointerup', 500, 500))
})
})
describe('handleResizeStart/Move/End', () => {
it('does nothing when imageUrl is null', () => {
const { handleResizeStart, cropWidth } = setup({ width: 200 })
handleResizeStart(createPointerEvent('pointerdown', 50, 50), 'se')
expect(cropWidth.value).toBe(200)
})
it('ignores resize move when not resizing', () => {
const { handleResizeMove, cropWidth } = setup({ width: 200 })
handleResizeMove(createPointerEvent('pointermove', 300, 300))
expect(cropWidth.value).toBe(200)
})
it('ignores resize end when not resizing', () => {
const { handleResizeEnd } = setup()
handleResizeEnd(createPointerEvent('pointerup', 200, 200))
})
it('resizes from the right edge', () => {
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
result.handleResizeMove(createPointerEvent('pointermove', 25, 0))
expect(result.cropWidth.value).toBe(250)
expect(result.cropX.value).toBe(100)
result.handleResizeEnd(createPointerEvent('pointerup', 25, 0))
})
it('resizes from the bottom edge', () => {
// effectiveScale = 0.5, so pointer delta 40px -> natural 80px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(
createPointerEvent('pointerdown', 0, 0),
'bottom'
)
result.handleResizeMove(createPointerEvent('pointermove', 0, 40))
expect(result.cropHeight.value).toBe(280)
expect(result.cropY.value).toBe(100)
result.handleResizeEnd(createPointerEvent('pointerup', 0, 40))
})
it('resizes from left edge, moving x and shrinking width', () => {
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 200,
y: 100,
width: 400,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'left')
result.handleResizeMove(createPointerEvent('pointermove', 50, 0))
// delta = 50/0.5 = 100 natural px; newX = 200+100 = 300, newW = 400-100 = 300
expect(result.cropX.value).toBe(300)
expect(result.cropWidth.value).toBe(300)
result.handleResizeEnd(createPointerEvent('pointerup', 50, 0))
})
it('enforces MIN_CROP_SIZE when resizing', () => {
// effectiveScale = 0.5, so pointer -200px -> natural -400px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 50,
height: 50
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
result.handleResizeMove(createPointerEvent('pointermove', -200, 0))
// MIN_CROP_SIZE = 16
expect(result.cropWidth.value).toBe(16)
result.handleResizeEnd(createPointerEvent('pointerup', -200, 0))
})
it('performs constrained resize with locked ratio from se corner', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.selectedRatio.value = '1:1'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'se')
result.handleResizeMove(createPointerEvent('pointermove', 100, 100))
// Both dimensions should grow equally for 1:1 ratio
expect(result.cropWidth.value).toBe(result.cropHeight.value)
expect(result.cropWidth.value).toBeGreaterThan(200)
result.handleResizeEnd(createPointerEvent('pointerup', 100, 100))
})
})
describe('getInputImageUrl (via imageUrl)', () => {
it('returns null when node is not found', () => {
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('returns URL from nodeOutputStore when node has output', () => {
const mockSourceNode = { isSubgraphNode: () => false }
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const store = useNodeOutputStore()
vi.mocked(store.getNodeImageUrls).mockReturnValue([
'http://example.com/output.png'
])
const { imageUrl } = setup()
expect(imageUrl.value).toBe('http://example.com/output.png')
})
it('returns null when source node has no output', () => {
const mockSourceNode = { isSubgraphNode: () => false }
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const store = useNodeOutputStore()
vi.mocked(store.getNodeImageUrls).mockReturnValue(undefined)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('returns null when node has no input node', () => {
const mockNode = createMockNode()
vi.mocked(resolveNode).mockReturnValue(mockNode)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('resolves subgraph node output link', () => {
const resolvedOutputNode = { isSubgraphNode: () => false }
const mockSourceNode = {
isSubgraphNode: () => true,
resolveSubgraphOutputLink: vi
.fn()
.mockReturnValue({ outputNode: resolvedOutputNode })
}
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const store = useNodeOutputStore()
vi.mocked(store.getNodeImageUrls).mockReturnValue([
'http://example.com/subgraph.png'
])
const { imageUrl } = setup()
expect(imageUrl.value).toBe('http://example.com/subgraph.png')
})
it('returns null when subgraph resolution fails (no link)', () => {
const mockSourceNode = {
isSubgraphNode: () => true,
resolveSubgraphOutputLink: vi.fn()
}
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
getInputLink: vi.fn().mockReturnValue(null)
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('returns null when subgraph resolves to no output node', () => {
const mockSourceNode = {
isSubgraphNode: () => true,
resolveSubgraphOutputLink: vi.fn().mockReturnValue({ outputNode: null })
}
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
})
describe('updateDisplayedDimensions (via handleImageLoad)', () => {
it('calculates scale for landscape image in smaller container', () => {
const result = setupWithImage(1000, 500, 500, 400)
// Landscape image: imageAspect=2 > containerAspect=1.25
// width-constrained: displayedWidth=500, scaleFactor=0.5
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.5}px`)
})
it('calculates scale for portrait image in wider container', () => {
const result = setupWithImage(500, 1000, 600, 400)
// Portrait: imageAspect=0.5 < containerAspect=1.5
// height-constrained: displayedWidth=200, scaleFactor=0.4
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.4}px`)
})
it('handles zero natural dimensions gracefully', () => {
const result = setupWithImage(0, 0, 500, 400, {
width: 512,
height: 512
})
// scaleFactor should default to 1
expect(result.cropBoxStyle.value.width).toBe(`${512}px`)
})
})
describe('initialize', () => {
it('calls resolveNode with the given nodeId', () => {
const mockNode = createMockNode()
vi.mocked(resolveNode).mockReturnValue(mockNode)
setup()
expect(resolveNode).toHaveBeenCalledWith(1)
})
it('sets node to null when resolveNode returns undefined', () => {
vi.mocked(resolveNode).mockReturnValue(undefined)
const { imageUrl } = setup()
expect(resolveNode).toHaveBeenCalledWith(1)
expect(imageUrl.value).toBeNull()
})
})
})

View File

@@ -323,38 +323,6 @@ describe('useGLSLPreview', () => {
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
})
it('uses custom resolution when size_mode is custom', async () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'custom' })
store._widgetMap.set('size_mode.width', { value: 800 })
store._widgetMap.set('size_mode.height', { value: 600 })
const node = createMockNode()
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(800, 600)
store._widgetMap.delete('size_mode')
store._widgetMap.delete('size_mode.width')
store._widgetMap.delete('size_mode.height')
})
it('uses default resolution when size_mode is not custom', async () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'from_input' })
const node = createMockNode()
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(512, 512)
store._widgetMap.delete('size_mode')
})
it('disposes renderer and cancels debounce on cleanup', async () => {
const node = createMockNode()
const { dispose } = await setupAndRender(node)

View File

@@ -282,44 +282,7 @@ function createInnerPreview(
}
}
function getCustomResolution(): [number, number] | null {
const gId = graphId.value
if (!gId) return null
const sizeModeNodeId = innerGLSLNode
? (innerGLSLNode.id as NodeId)
: nodeId.value
if (sizeModeNodeId == null) return null
const sizeMode = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode'
)
if (sizeMode?.value !== 'custom') return null
const widthWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.height'
)
if (!widthWidget || !heightWidget) return null
return clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
}
function getResolution(): [number, number] {
const custom = getCustomResolution()
if (custom) return custom
const node = nodeRef.value
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
@@ -362,6 +325,27 @@ function createInnerPreview(
}
}
const gId = graphId.value
const nId = nodeId.value
if (gId && nId != null) {
const widthWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.height'
)
if (widthWidget && heightWidget) {
return clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
}
}
return [DEFAULT_SIZE, DEFAULT_SIZE]
}

View File

@@ -1,115 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import {
extractUniformSources,
toNumber
} from '@/renderer/glsl/useGLSLUniforms'
function createMockSubgraph(
links: Record<number, { origin_id: number; origin_slot: number }>,
nodes: Record<
number,
{ id: number; widgets: Array<{ name: string; value: unknown }> }
>
) {
return fromAny<Subgraph, unknown>({
getLink: (id: number) => links[id] ?? null,
getNodeById: (id: number) => nodes[id] ?? null
})
}
describe('extractUniformSources', () => {
it('uses origin_slot to select the correct widget from source node', () => {
const glslNode = fromAny<LGraphNode, unknown>({
inputs: [
{ name: 'ints.u_int0', link: 1 },
{ name: 'ints.u_int1', link: 2 }
]
})
const subgraph = createMockSubgraph(
{
1: { origin_id: 10, origin_slot: 1 },
2: { origin_id: 20, origin_slot: 0 }
},
{
10: {
id: 10,
widgets: [
{ name: 'choice', value: 'Master' },
{ name: 'index', value: 0 }
]
},
20: {
id: 20,
widgets: [{ name: 'value', value: 42 }]
}
}
)
const result = extractUniformSources(glslNode, subgraph)
expect(result.ints[0].widgetName).toBe('index')
expect(result.ints[1].widgetName).toBe('value')
})
it('skips source when origin_slot exceeds widget count', () => {
const glslNode = fromAny<LGraphNode, unknown>({
inputs: [{ name: 'floats.u_float0', link: 1 }]
})
const subgraph = createMockSubgraph(
{ 1: { origin_id: 10, origin_slot: 5 } },
{ 10: { id: 10, widgets: [{ name: 'value', value: 3.14 }] } }
)
const result = extractUniformSources(glslNode, subgraph)
expect(result.floats).toHaveLength(0)
})
it('provides directValue getter that reads from the widget', () => {
const indexWidget = {
name: 'index',
get value() {
return choiceWidget.value === 'Reds' ? 1 : 0
}
}
const choiceWidget = { name: 'choice', value: 'Master' }
const glslNode = fromAny<LGraphNode, unknown>({
inputs: [{ name: 'ints.u_int0', link: 1 }]
})
const subgraph = createMockSubgraph(
{ 1: { origin_id: 10, origin_slot: 1 } },
{ 10: { id: 10, widgets: [choiceWidget, indexWidget] } }
)
const result = extractUniformSources(glslNode, subgraph)
expect(result.ints[0].directValue()).toBe(0)
choiceWidget.value = 'Reds'
expect(result.ints[0].directValue()).toBe(1)
})
})
describe('toNumber', () => {
it('coerces hex color strings via hexToInt', () => {
expect(toNumber('#45edf5')).toBe(0x45edf5)
})
it('coerces plain numeric values', () => {
expect(toNumber(42)).toBe(42)
expect(toNumber('10')).toBe(10)
})
it('returns 0 for non-numeric strings', () => {
expect(toNumber('Master')).toBe(0)
})
})

View File

@@ -10,7 +10,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isCurveData } from '@/components/curve/curveUtils'
import type { CurveData } from '@/components/curve/types'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import { hexToInt } from '@/utils/colorUtil'
interface AutogrowGroup {
max: number
@@ -21,8 +20,6 @@ interface AutogrowGroup {
interface UniformSource {
nodeId: NodeId
widgetName: string
/** Fallback getter for widgets not registered in widgetValueStore (e.g. hidden computed widgets). */
directValue: () => unknown
}
interface UniformSources {
@@ -81,19 +78,16 @@ export function extractUniformSources(
if (!link || link.origin_id === SUBGRAPH_INPUT_ID) continue
const sourceNode = subgraph.getNodeById(link.origin_id)
if (!sourceNode?.widgets?.length) continue
if (!sourceNode?.widgets?.[0]) continue
const inputName = input.name ?? ''
const dotIndex = inputName.indexOf('.')
if (dotIndex === -1) continue
const prefix = inputName.slice(0, dotIndex)
if (link.origin_slot >= sourceNode.widgets.length) continue
const widget = sourceNode.widgets[link.origin_slot]
const source: UniformSource = {
nodeId: sourceNode.id as NodeId,
widgetName: widget.name,
directValue: () => widget.value
widgetName: sourceNode.widgets[0].name
}
if (prefix === 'floats') floats.push(source)
@@ -105,11 +99,6 @@ export function extractUniformSources(
return { floats, ints, bools, curves }
}
export function toNumber(v: unknown): number {
if (typeof v === 'string' && v.startsWith('#')) return hexToInt(v)
return Number(v) || 0
}
export function useGLSLUniforms(
graphId: ComputedRef<UUID | undefined>,
nodeId: ComputedRef<NodeId | undefined>,
@@ -131,9 +120,9 @@ export function useGLSLUniforms(
if (!gId) return []
if (subgraphSources) {
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
return subgraphSources.map(({ nodeId: nId, widgetName }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
return coerce(widget?.value ?? directValue() ?? defaultValue)
return coerce(widget?.value ?? defaultValue)
})
}
@@ -153,24 +142,19 @@ export function useGLSLUniforms(
const slot = node.inputs?.findIndex((inp) => inp.name === inputName)
if (slot == null || slot < 0) break
const link = node.getInputLink(slot)
if (!link) break
const upstreamNode = node.getInputNode(slot)
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
)
if (
upstreamWidgets.length === 0 ||
link.origin_slot >= upstreamWidgets.length
)
break
values.push(coerce(upstreamWidgets[link.origin_slot].value))
if (upstreamWidgets.length === 0) break
values.push(coerce(upstreamWidgets[0].value))
}
return values
}
const toNumber = (v: unknown): number => Number(v) || 0
const toBool = (v: unknown): boolean => Boolean(v)
const floatValues = computed(() =>
@@ -213,10 +197,11 @@ export function useGLSLUniforms(
const sources = uniformSources.value?.curves
if (sources && sources.length > 0) {
return sources
.map(({ nodeId: nId, widgetName, directValue }) => {
.map(({ nodeId: nId, widgetName }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const value = widget?.value ?? directValue()
return isCurveData(value) ? (value as CurveData) : null
return widget && isCurveData(widget.value)
? (widget.value as CurveData)
: null
})
.filter((v): v is CurveData => v !== null)
}

View File

@@ -1,6 +1,7 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -331,9 +332,12 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
handler(
new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
)
// Flush the RAF so the batched update is applied immediately
vi.advanceTimersByTime(16)
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -341,6 +345,10 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
it('should retain entries below the limit', () => {
for (let i = 0; i < 5; i++) {
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
@@ -695,3 +703,309 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
expect(store.missingNodesError?.nodeTypes).toEqual(input)
})
})
describe('useExecutionStore - RAF batching', () => {
let store: ReturnType<typeof useExecutionStore>
function getRegisteredHandler(eventName: string) {
const calls = vi.mocked(api.addEventListener).mock.calls
const call = calls.find(([name]) => name === eventName)
return call?.[1] as (e: CustomEvent) => void
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
describe('handleProgress', () => {
function makeProgressEvent(value: number, max: number): CustomEvent {
return new CustomEvent('progress', {
detail: { value, max, prompt_id: 'job-1', node: '1' }
})
}
it('batches multiple progress events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
handler(makeProgressEvent(5, 10))
handler(makeProgressEvent(9, 10))
expect(store._executingNodeProgress).toBeNull()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual({
value: 9,
max: 10,
prompt_id: 'job-1',
node: '1'
})
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(3, 10))
expect(store._executingNodeProgress).toBeNull()
})
it('allows a new batch after the previous RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 1 })
)
handler(makeProgressEvent(7, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 7 })
)
})
})
describe('handleProgressState', () => {
function makeProgressStateEvent(
nodeId: string,
state: string,
value = 0,
max = 10
): CustomEvent {
return new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
[nodeId]: {
value,
max,
state,
node_id: nodeId,
prompt_id: 'job-1',
display_node_id: nodeId
}
}
}
})
}
it('batches multiple progress_state events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running', 1))
handler(makeProgressStateEvent('1', 'running', 5))
handler(makeProgressStateEvent('1', 'running', 9))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
vi.advanceTimersByTime(16)
expect(store.nodeProgressStates['1']).toEqual(
expect.objectContaining({ value: 9, state: 'running' })
)
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running'))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
describe('pending RAF is discarded when execution completes', () => {
it('discards pending progress RAF on execution_success', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress_state RAF on execution_success', () => {
const progressStateHandler = getRegisteredHandler('progress_state')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressStateHandler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 5,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
it('discards pending progress RAF on execution_error', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const errorHandler = getRegisteredHandler('execution_error')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
errorHandler(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
exception_message: 'error',
exception_type: 'RuntimeError',
traceback: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress RAF on execution_interrupted', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const interruptedHandler = getRegisteredHandler('execution_interrupted')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
interruptedHandler(
new CustomEvent('execution_interrupted', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
executed: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
})
describe('unbindExecutionEvents cancels pending RAFs', () => {
it('cancels pending progress RAF on unbind', () => {
const handler = getRegisteredHandler('progress')
handler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('cancels pending progress_state RAF on unbind', () => {
const handler = getRegisteredHandler('progress_state')
handler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 0,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
})

View File

@@ -33,6 +33,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import { createRafCoalescer } from '@/utils/rafBatch'
interface QueuedJob {
/**
@@ -242,6 +243,8 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
cancelPendingProgressUpdates()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -290,6 +293,10 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecuting(e: CustomEvent<NodeId | null>): void {
// Cancel any pending progress RAF before clearing state to prevent
// stale data from being written back on the next frame.
progressCoalescer.cancel()
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -332,8 +339,15 @@ export const useExecutionStore = defineStore('execution', () => {
nodeProgressStatesByJob.value = pruned
}
const progressStateCoalescer =
createRafCoalescer<ProgressStateWsMessage>(_applyProgressState)
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
const { nodes, prompt_id: jobId } = e.detail
progressStateCoalescer.push(e.detail)
}
function _applyProgressState(detail: ProgressStateWsMessage) {
const { nodes, prompt_id: jobId } = detail
// Revoke previews for nodes that are starting to execute
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
@@ -369,8 +383,17 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
const progressCoalescer = createRafCoalescer<ProgressWsMessage>((detail) => {
_executingNodeProgress.value = detail
})
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
_executingNodeProgress.value = e.detail
progressCoalescer.push(e.detail)
}
function cancelPendingProgressUpdates() {
progressCoalescer.cancel()
progressStateCoalescer.cancel()
}
function handleStatus() {
@@ -492,6 +515,8 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: string | null) {
cancelPendingProgressUpdates()
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null

View File

@@ -4,7 +4,6 @@ import type { ColorAdjustOptions } from '@/utils/colorUtil'
import {
adjustColor,
hexToHsva,
hexToInt,
hexToRgb,
hsbToRgb,
hsvaToHex,
@@ -96,20 +95,6 @@ describe('colorUtil conversions', () => {
})
})
describe('hexToInt', () => {
it('converts 6-digit hex to packed integer', () => {
expect(hexToInt('#ff0000')).toBe(0xff0000)
expect(hexToInt('#00ff00')).toBe(0x00ff00)
expect(hexToInt('#45edf5')).toBe(0x45edf5)
expect(hexToInt('#000000')).toBe(0)
})
it('converts 3-digit hex to packed integer', () => {
expect(hexToInt('#fff')).toBe(0xffffff)
expect(hexToInt('#f00')).toBe(0xff0000)
})
})
describe('parseToRgb', () => {
it('parses #hex', () => {
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })

View File

@@ -82,11 +82,6 @@ export function hexToRgb(hex: string): RGB {
return { r, g, b }
}
export function hexToInt(hex: string): number {
const { r, g, b } = hexToRgb(hex)
return (r << 16) | (g << 8) | b
}
export function rgbToHex({ r, g, b }: RGB): string {
const toHex = (n: number) =>
Math.max(0, Math.min(255, Math.round(n)))

View File

@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRafCoalescer } from '@/utils/rafBatch'
describe('createRafCoalescer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('applies the latest pushed value on the next frame', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.push(2)
coalescer.push(3)
expect(apply).not.toHaveBeenCalled()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(3)
})
it('does not apply after cancel', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(42)
coalescer.cancel()
vi.advanceTimersByTime(16)
expect(apply).not.toHaveBeenCalled()
})
it('applies immediately on flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(99)
coalescer.flush()
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(99)
})
it('does nothing on flush when no value is pending', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.flush()
expect(apply).not.toHaveBeenCalled()
})
it('does not double-apply after flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.flush()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
})
it('reports scheduled state correctly', () => {
const coalescer = createRafCoalescer<number>(vi.fn())
expect(coalescer.isScheduled()).toBe(false)
coalescer.push(1)
expect(coalescer.isScheduled()).toBe(true)
vi.advanceTimersByTime(16)
expect(coalescer.isScheduled()).toBe(false)
})
})

View File

@@ -27,3 +27,40 @@ export function createRafBatch(run: () => void) {
return { schedule, cancel, flush, isScheduled }
}
/**
* Last-write-wins RAF coalescer. Buffers the latest value and applies it
* on the next animation frame, coalescing multiple pushes into a single
* reactive update.
*/
export function createRafCoalescer<T>(apply: (value: T) => void) {
let hasPending = false
let pendingValue: T | undefined
const batch = createRafBatch(() => {
if (!hasPending) return
const value = pendingValue as T
hasPending = false
pendingValue = undefined
apply(value)
})
const push = (value: T) => {
pendingValue = value
hasPending = true
batch.schedule()
}
const cancel = () => {
hasPending = false
pendingValue = undefined
batch.cancel()
}
const flush = () => {
if (!hasPending) return
batch.flush()
}
return { push, cancel, flush, isScheduled: batch.isScheduled }
}