mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
69 Commits
sno-qa-399
...
bl/assets-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
051039aa32 | ||
|
|
66066c0dce | ||
|
|
b61174c0ea | ||
|
|
2524846f5c | ||
|
|
12f578870e | ||
|
|
90b24785b3 | ||
|
|
f234dd9ebb | ||
|
|
200d7328ef | ||
|
|
72eed86cea | ||
|
|
ce3a76893e | ||
|
|
719ed16d32 | ||
|
|
5899a9392e | ||
|
|
dcf2fbdcc2 | ||
|
|
e39468567a | ||
|
|
a373633ab2 | ||
|
|
521019d173 | ||
|
|
bd82c855e0 | ||
|
|
5b7ef3fe21 | ||
|
|
85de833776 | ||
|
|
cab46567c0 | ||
|
|
63435bdb34 | ||
|
|
20255da61f | ||
|
|
c2dba8f4ee | ||
|
|
6f579c5992 | ||
|
|
e729e5edb8 | ||
|
|
3043b181d7 | ||
|
|
8c9328c1b2 | ||
|
|
577f373cde | ||
|
|
44f88027b6 | ||
|
|
5d07de1913 | ||
|
|
f0ae91de43 | ||
|
|
fb8025c49f | ||
|
|
5c14badc42 | ||
|
|
82fb8ce658 | ||
|
|
c3e823e55b | ||
|
|
ebc9025de5 | ||
|
|
0353524e6f | ||
|
|
0a041e5e63 | ||
|
|
c4f468366c | ||
|
|
f1bb756929 | ||
|
|
ff93926e5e | ||
|
|
d70c3cbfc2 | ||
|
|
d67f385026 | ||
|
|
e38dd1efae | ||
|
|
62779d3c51 | ||
|
|
4cf160d66e | ||
|
|
c0871ba219 | ||
|
|
6d4fc1bb10 | ||
|
|
2699a4d78f | ||
|
|
f7bd8e58af | ||
|
|
d651f562d7 | ||
|
|
3a22391bd2 | ||
|
|
04a62fa86f | ||
|
|
467c556da3 | ||
|
|
a721bdc67b | ||
|
|
728f3fb764 | ||
|
|
40cfad5361 | ||
|
|
a8ebcfb6e9 | ||
|
|
3ae1fd692c | ||
|
|
22e318535c | ||
|
|
50de97488b | ||
|
|
058057f042 | ||
|
|
d1ac778add | ||
|
|
c2d1c59b09 | ||
|
|
c0c0f00c24 | ||
|
|
3654bd7e08 | ||
|
|
fcb635511a | ||
|
|
268a804b58 | ||
|
|
920f4f8103 |
@@ -1,361 +0,0 @@
|
||||
---
|
||||
name: ticket-intake
|
||||
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
|
||||
---
|
||||
|
||||
# Ticket Intake
|
||||
|
||||
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
|
||||
|
||||
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
|
||||
|
||||
## Supported Sources
|
||||
|
||||
| Source | URL Pattern | Provider File |
|
||||
| ------ | --------------------------------------------------- | --------------------- |
|
||||
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
|
||||
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
When given a ticket URL:
|
||||
|
||||
1. **Detect source type** from URL pattern
|
||||
2. **Load provider-specific logic** from `providers/` directory
|
||||
3. Fetch ticket content via appropriate API
|
||||
4. Extract and normalize properties to common schema
|
||||
5. **Register ticket in pipeline API** ← MANDATORY
|
||||
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
|
||||
7. **Run verification script** to confirm API registration
|
||||
8. Output summary and handoff to `research-orchestrator`
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses the **production API** by default. No configuration needed for read operations.
|
||||
|
||||
**Defaults (no setup required):**
|
||||
|
||||
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
|
||||
- Read-only endpoints at `/public/*` require no authentication
|
||||
|
||||
**For write operations** (transitions, creating tickets), set:
|
||||
|
||||
```bash
|
||||
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
|
||||
```
|
||||
|
||||
**Optional (for local working artifacts):**
|
||||
|
||||
```bash
|
||||
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
|
||||
```
|
||||
|
||||
## Mandatory API Calls (Execute ALL Three)
|
||||
|
||||
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
|
||||
|
||||
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
|
||||
|
||||
### Call 1: Create Ticket
|
||||
|
||||
```bash
|
||||
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
|
||||
API_KEY="${PIPELINE_API_KEY}"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"notion_page_id": "NOTION_PAGE_UUID_HERE",
|
||||
"title": "TICKET_TITLE_HERE",
|
||||
"source": "notion",
|
||||
"metadata": {
|
||||
"description": "DESCRIPTION_HERE",
|
||||
"priority": "High",
|
||||
"labels": [],
|
||||
"acceptanceCriteria": []
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Save the returned `id` — you need it for the next two calls.
|
||||
|
||||
### Call 2: Transition to RESEARCH
|
||||
|
||||
```bash
|
||||
TICKET_ID="id-from-step-1"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"to_state": "RESEARCH",
|
||||
"reason": "Intake complete, starting research"
|
||||
}'
|
||||
```
|
||||
|
||||
### Call 3: Queue Source Update
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${API_URL}/v1/sync/queue" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"ticket_id": "TICKET_ID_HERE",
|
||||
"action": "update_status",
|
||||
"payload": { "status": "In Progress" },
|
||||
"priority": "normal"
|
||||
}'
|
||||
```
|
||||
|
||||
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
|
||||
|
||||
### TypeScript Equivalent (if using pipeline client)
|
||||
|
||||
```typescript
|
||||
import { PipelineClient } from '@pipeline/client'
|
||||
|
||||
const client = new PipelineClient({
|
||||
apiUrl:
|
||||
process.env.PIPELINE_API_URL ||
|
||||
'https://api-gateway-856475788601.us-central1.run.app',
|
||||
agentId: process.env.AGENT_ID!
|
||||
})
|
||||
|
||||
const ticket = await client.createTicket({
|
||||
notion_page_id: pageId,
|
||||
title: ticketTitle,
|
||||
source: 'notion',
|
||||
metadata: { description, priority, labels, acceptanceCriteria }
|
||||
})
|
||||
|
||||
await client.transitionState(
|
||||
ticket.id,
|
||||
'RESEARCH',
|
||||
'Intake complete, starting research'
|
||||
)
|
||||
|
||||
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Detect Source Type
|
||||
|
||||
Parse the URL to determine source:
|
||||
|
||||
```javascript
|
||||
if (url.includes('notion.so')) {
|
||||
source = 'notion'
|
||||
// Load providers/notion.md
|
||||
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
|
||||
source = 'github'
|
||||
// Load providers/github.md
|
||||
} else {
|
||||
// Error: Unsupported URL format
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Load Provider and Fetch Data
|
||||
|
||||
Read the appropriate provider file for source-specific instructions:
|
||||
|
||||
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
|
||||
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
|
||||
|
||||
Follow the provider's instructions for:
|
||||
|
||||
- Fetching content
|
||||
- Extracting properties
|
||||
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
|
||||
|
||||
### Step 3: Normalize to Common Schema
|
||||
|
||||
All providers must extract normalized ticket data following `schema.md`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "abc12345",
|
||||
"url": "https://...",
|
||||
"source": "notion | github",
|
||||
"title": "Ticket title",
|
||||
"description": "Full description",
|
||||
"status": "Not Started",
|
||||
"assignee": "username",
|
||||
"priority": "High",
|
||||
"area": "UI",
|
||||
"labels": ["bug", "frontend"],
|
||||
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
|
||||
"fetchedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
|
||||
|
||||
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
|
||||
|
||||
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
|
||||
|
||||
1. `createTicket()` → save the returned ticket ID
|
||||
2. `transitionState(id, 'RESEARCH')` → confirm state changed
|
||||
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
|
||||
|
||||
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
|
||||
|
||||
### Step 5: Run Verification Script
|
||||
|
||||
After making the API calls, run the verification script to confirm everything worked:
|
||||
|
||||
```bash
|
||||
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
|
||||
```
|
||||
|
||||
**If the script is not available locally**, verify manually via the public API:
|
||||
|
||||
```bash
|
||||
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"state": "RESEARCH",
|
||||
"title": "...",
|
||||
"notion_page_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
|
||||
|
||||
### Step 6: Output Summary and Handoff
|
||||
|
||||
Print a clear summary:
|
||||
|
||||
```markdown
|
||||
## Ticket Intake Complete
|
||||
|
||||
**Source:** Notion | GitHub
|
||||
**Title:** [Ticket title]
|
||||
**ID:** abc12345
|
||||
**Status:** In Progress (queued)
|
||||
**Priority:** High
|
||||
**Area:** UI
|
||||
|
||||
### Description
|
||||
|
||||
[Brief description or first 200 chars]
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
|
||||
### Links
|
||||
|
||||
- **Ticket:** [Original URL]
|
||||
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
|
||||
|
||||
### Pipeline
|
||||
|
||||
- **API Ticket ID:** abc12345
|
||||
- **State:** RESEARCH
|
||||
- **Verified:** ✅ (via verify-intake.sh or public API)
|
||||
```
|
||||
|
||||
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
|
||||
|
||||
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
|
||||
|
||||
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Unsupported URL
|
||||
|
||||
```
|
||||
❌ Unsupported ticket URL format.
|
||||
|
||||
Supported formats:
|
||||
- Notion: https://notion.so/... or https://www.notion.so/...
|
||||
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
|
||||
|
||||
Received: [provided URL]
|
||||
```
|
||||
|
||||
### Provider-Specific Errors
|
||||
|
||||
See individual provider files for source-specific error handling:
|
||||
|
||||
- `providers/notion.md` - Authentication, page not found
|
||||
- `providers/github.md` - Auth, rate limits, issue not found
|
||||
|
||||
### Missing Properties
|
||||
|
||||
Continue with available data and note what's missing:
|
||||
|
||||
```
|
||||
⚠️ Some properties unavailable:
|
||||
- Priority: not found (using default: Medium)
|
||||
- Area: not found
|
||||
|
||||
Proceeding with available data...
|
||||
```
|
||||
|
||||
### API Call Failures
|
||||
|
||||
```
|
||||
❌ Pipeline API call failed: {method} {endpoint}
|
||||
Status: {status}
|
||||
Error: {message}
|
||||
|
||||
Retrying once...
|
||||
|
||||
❌ Retry also failed. INTAKE IS INCOMPLETE.
|
||||
The ticket was NOT registered in the pipeline.
|
||||
Downstream skills will not work until this is fixed.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill focuses ONLY on intake — it does not do research
|
||||
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
|
||||
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
|
||||
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
|
||||
- Pipeline state is tracked via the API, not local files
|
||||
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
|
||||
- The `source` field in the ticket determines which research strategies to use
|
||||
|
||||
## API Client Reference
|
||||
|
||||
### Available Methods
|
||||
|
||||
| Method | Description |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
|
||||
| `getTicket(id)` | Retrieve a ticket by ID |
|
||||
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
|
||||
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
|
||||
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
|
||||
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
|
||||
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
|
||||
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
|
||||
|
||||
try {
|
||||
await client.createTicket({ ... });
|
||||
} catch (error) {
|
||||
if (error instanceof PipelineAPIError) {
|
||||
console.error(`API Error ${error.status}: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
@@ -1,194 +0,0 @@
|
||||
# GitHub Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from GitHub Issues.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://github.com/{owner}/{repo}/issues/{number}
|
||||
https://www.github.com/{owner}/{repo}/issues/{number}
|
||||
```
|
||||
|
||||
Extract: `owner`, `repo`, `issue_number` from URL.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Access to the repository
|
||||
|
||||
## Fetch Issue Content
|
||||
|
||||
Use `gh` CLI to fetch issue details:
|
||||
|
||||
```bash
|
||||
# Get issue details in JSON
|
||||
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
|
||||
|
||||
# Get comments separately if needed
|
||||
gh issue view {number} --repo {owner}/{repo} --comments
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Map GitHub issue fields to normalized ticket data (stored via API):
|
||||
|
||||
| GitHub Field | ticket.json Field | Notes |
|
||||
| ------------ | ----------------- | -------------------------- |
|
||||
| title | title | Direct mapping |
|
||||
| body | description | Issue body/description |
|
||||
| state | status | Map: open → "Not Started" |
|
||||
| labels | labels | Array of label names |
|
||||
| assignees | assignee | First assignee login |
|
||||
| author | author | Issue author login |
|
||||
| milestone | milestone | Milestone title if present |
|
||||
| comments | comments | Array of comment objects |
|
||||
| linkedPRs | linkedPRs | PRs linked to this issue |
|
||||
|
||||
### Priority Mapping
|
||||
|
||||
Infer priority from labels:
|
||||
|
||||
- `priority:critical`, `P0` → "Critical"
|
||||
- `priority:high`, `P1` → "High"
|
||||
- `priority:medium`, `P2` → "Medium"
|
||||
- `priority:low`, `P3` → "Low"
|
||||
- No priority label → "Medium" (default)
|
||||
|
||||
### Area Mapping
|
||||
|
||||
Infer area from labels:
|
||||
|
||||
- `area:ui`, `frontend`, `component:*` → "UI"
|
||||
- `area:api`, `backend` → "API"
|
||||
- `area:docs`, `documentation` → "Docs"
|
||||
- `bug`, `fix` → "Bug"
|
||||
- `enhancement`, `feature` → "Feature"
|
||||
|
||||
## Update Source
|
||||
|
||||
**For GitHub issues, update is optional but recommended.**
|
||||
|
||||
Add a comment to indicate work has started:
|
||||
|
||||
```bash
|
||||
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
|
||||
```
|
||||
|
||||
Optionally assign to self:
|
||||
|
||||
```bash
|
||||
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
|
||||
```
|
||||
|
||||
Log any updates via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
githubWrites: [
|
||||
...(ticket.metadata?.githubWrites || []),
|
||||
{
|
||||
action: 'comment',
|
||||
issueNumber: 123,
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## GitHub-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "github",
|
||||
"githubOwner": "Comfy-Org",
|
||||
"githubRepo": "ComfyUI_frontend",
|
||||
"githubIssueNumber": 123,
|
||||
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
|
||||
"labels": ["bug", "area:ui", "priority:high"],
|
||||
"linkedPRs": [456, 789],
|
||||
"dosuComment": "..." // Extracted Dosu bot analysis if present
|
||||
}
|
||||
```
|
||||
|
||||
## Dosu Bot Detection
|
||||
|
||||
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
|
||||
|
||||
```bash
|
||||
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
|
||||
```
|
||||
|
||||
Look for comments from:
|
||||
|
||||
- `dosu[bot]`
|
||||
- `dosu-bot`
|
||||
|
||||
Extract Dosu analysis which typically includes:
|
||||
|
||||
- Root cause analysis
|
||||
- Suggested files to modify
|
||||
- Related issues/PRs
|
||||
- Potential solutions
|
||||
|
||||
Store in ticket data via API:
|
||||
|
||||
```json
|
||||
{
|
||||
"dosuComment": {
|
||||
"found": true,
|
||||
"analysis": "...",
|
||||
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
|
||||
"relatedIssues": [100, 101]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Extract Linked Issues/PRs
|
||||
|
||||
Parse issue body and comments for references:
|
||||
|
||||
- `#123` → Issue or PR reference
|
||||
- `fixes #123`, `closes #123` → Linked issue
|
||||
- `https://github.com/.../issues/123` → Full URL reference
|
||||
|
||||
Store in ticket data via API for research phase:
|
||||
|
||||
```json
|
||||
{
|
||||
"referencedIssues": [100, 101, 102],
|
||||
"referencedPRs": [200, 201]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ GitHub CLI not authenticated.
|
||||
Run: gh auth login
|
||||
```
|
||||
|
||||
### Issue Not Found
|
||||
|
||||
```
|
||||
❌ GitHub issue not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this repository
|
||||
- Run: gh auth status
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```
|
||||
⚠️ GitHub API rate limited.
|
||||
Wait a few minutes and try again.
|
||||
Check status: gh api rate_limit
|
||||
```
|
||||
@@ -1,202 +0,0 @@
|
||||
# Notion Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from Notion.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://www.notion.so/workspace/Page-Title-abc123def456...
|
||||
https://notion.so/Page-Title-abc123def456...
|
||||
https://www.notion.so/abc123def456...
|
||||
```
|
||||
|
||||
Page ID is the 32-character hex string (with or without hyphens).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Notion MCP connected and authenticated
|
||||
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
|
||||
- Authenticate via `/mcp` command if prompted
|
||||
|
||||
## Fetch Ticket Content
|
||||
|
||||
Use `Notion:notion-fetch` with the page URL or ID:
|
||||
|
||||
```
|
||||
Fetch the full page content including all properties
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Extract these properties (names may vary):
|
||||
|
||||
| Property | Expected Name | Type |
|
||||
| ------------- | ------------------------- | ------------ |
|
||||
| Title | Name / Title | Title |
|
||||
| Status | Status | Select |
|
||||
| Assignee | Assignee / Assigned To | Person |
|
||||
| Description | - | Page content |
|
||||
| Slack Link | Slack Link / Slack Thread | URL |
|
||||
| GitHub PR | GitHub PR / PR Link | URL |
|
||||
| Priority | Priority | Select |
|
||||
| Area | Area / Category | Select |
|
||||
| Related Tasks | Related Tasks | Relation |
|
||||
|
||||
**If properties are missing**: Note what's unavailable and continue with available data.
|
||||
|
||||
## Update Source (REQUIRED)
|
||||
|
||||
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
|
||||
|
||||
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
|
||||
|
||||
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
|
||||
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
|
||||
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
|
||||
|
||||
Use `Notion:notion-update-page` to update the ticket:
|
||||
|
||||
1. **Status**: Set to "In Progress" (only valid from "Not Started")
|
||||
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
|
||||
|
||||
```json
|
||||
{
|
||||
"page_id": "{page_id_from_ticket}",
|
||||
"command": "update_properties",
|
||||
"properties": {
|
||||
"Status": "In Progress",
|
||||
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After the update succeeds**, log the write via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
notionWrites: [
|
||||
...(ticket.metadata?.notionWrites || []),
|
||||
{
|
||||
field: 'Status',
|
||||
value: 'In Progress',
|
||||
previousValue: 'Not Started',
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
If update fails, log with `success: false` and continue.
|
||||
|
||||
## Notion-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "notion",
|
||||
"notionPageId": "abc123def456...",
|
||||
"slackLink": "https://slack.com/...",
|
||||
"relatedTasks": ["page-id-1", "page-id-2"]
|
||||
}
|
||||
```
|
||||
|
||||
## Slack Thread Handling
|
||||
|
||||
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
|
||||
|
||||
### Slack URL Conversion
|
||||
|
||||
Notion stores Slack links in `slackMessage://` format:
|
||||
|
||||
```
|
||||
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
|
||||
```
|
||||
|
||||
Convert to browser-clickable format:
|
||||
|
||||
```
|
||||
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
|
||||
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
|
||||
|
||||
(Remove the dot from the last timestamp and prefix with `p`)
|
||||
|
||||
### Fetching Thread Content
|
||||
|
||||
Load the `slackdump` skill and use the **export-thread** workflow:
|
||||
|
||||
```bash
|
||||
# Export thread by URL
|
||||
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
|
||||
|
||||
# Or by colon notation (channel_id:thread_ts)
|
||||
slackdump dump CHANNEL_ID:THREAD_TS
|
||||
```
|
||||
|
||||
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
|
||||
|
||||
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
|
||||
|
||||
## Database Reference: Comfy Tasks
|
||||
|
||||
The "Comfy Tasks" database has these properties (verify via `notion-search`):
|
||||
|
||||
- **Status values**: Not Started, In Progress, In Review, Done
|
||||
- **Team assignment**: "Frontend Team" for unassigned tickets
|
||||
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
|
||||
|
||||
### Pipeline Owner Details
|
||||
|
||||
When assigning tickets, use these identifiers:
|
||||
|
||||
| Platform | Identifier |
|
||||
| --------------- | -------------------------------------- |
|
||||
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
|
||||
| Notion Name | Christian Byrne |
|
||||
| Notion Email | cbyrne@comfy.org |
|
||||
| Slack User ID | U087MJCDHHC |
|
||||
| GitHub Username | christian-byrne |
|
||||
|
||||
**To update Assignee**, use the Notion User ID (not name):
|
||||
|
||||
```
|
||||
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
|
||||
```
|
||||
|
||||
### Finding Active Tickets
|
||||
|
||||
To list your active tickets:
|
||||
|
||||
```
|
||||
Use Notion:notion-search for "Comfy Tasks"
|
||||
Filter by Assignee = current user OR Team = "Frontend Team"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ Notion authentication required.
|
||||
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
|
||||
Then authenticate via /mcp command.
|
||||
```
|
||||
|
||||
### Page Not Found
|
||||
|
||||
```
|
||||
❌ Notion page not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this page
|
||||
- Try re-authenticating via /mcp
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
# Ticket Schema
|
||||
|
||||
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
|
||||
|
||||
## Ticket Data Schema
|
||||
|
||||
```json
|
||||
{
|
||||
// Required fields (all sources)
|
||||
"id": "string", // Unique identifier (short form)
|
||||
"url": "string", // Original URL
|
||||
"source": "notion | github", // Source type
|
||||
"title": "string", // Ticket title
|
||||
"description": "string", // Full description/body
|
||||
"fetchedAt": "ISO8601", // When ticket was fetched
|
||||
|
||||
// Common optional fields
|
||||
"status": "string", // Current status
|
||||
"assignee": "string", // Assigned user
|
||||
"priority": "string", // Priority level
|
||||
"area": "string", // Category/area
|
||||
"labels": ["string"], // Tags/labels
|
||||
"acceptanceCriteria": ["string"] // List of AC items
|
||||
|
||||
// Source-specific fields (see providers)
|
||||
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
|
||||
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
|
||||
}
|
||||
```
|
||||
|
||||
## Ticket State Schema (via API)
|
||||
|
||||
State is managed via the Pipeline API using `client.transitionState()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ticketId": "string",
|
||||
"state": "intake | research | planning | implementation | pr_created | done | failed",
|
||||
"stateChangedAt": "ISO8601",
|
||||
|
||||
// Timestamps tracked by API
|
||||
"createdAt": "ISO8601",
|
||||
"updatedAt": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
## Priority Normalization
|
||||
|
||||
All sources should normalize to these values:
|
||||
|
||||
| Normalized | Description |
|
||||
| ---------- | ------------------------- |
|
||||
| Critical | Production down, security |
|
||||
| High | Blocking work, urgent |
|
||||
| Medium | Normal priority (default) |
|
||||
| Low | Nice to have, backlog |
|
||||
|
||||
## Status Normalization
|
||||
|
||||
Pipeline tracks these statuses internally:
|
||||
|
||||
| Status | Description |
|
||||
| -------------- | ---------------------------- |
|
||||
| research | Gathering context |
|
||||
| planning | Creating implementation plan |
|
||||
| implementation | Writing code |
|
||||
| review | Code review in progress |
|
||||
| qa | Quality assurance |
|
||||
| done | PR merged or completed |
|
||||
|
||||
## ID Generation
|
||||
|
||||
IDs are generated by the API when creating tickets. For reference:
|
||||
|
||||
- **Notion**: First 8 characters of page ID
|
||||
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
|
||||
|
||||
Examples:
|
||||
|
||||
- Notion: `abc12345`
|
||||
- GitHub: `gh-comfy-org-frontend-123`
|
||||
31
.github/actions/lint-format-verify/action.yml
vendored
Normal file
31
.github/actions/lint-format-verify/action.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: 'Lint and format verify'
|
||||
description: >
|
||||
Runs the lint/format/knip verification suite plus a conditional
|
||||
browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and
|
||||
ci-lint-format-queue.yaml (merge queue) so both paths run the exact
|
||||
same checks. The caller is responsible for checkout and frontend setup
|
||||
before invoking this action.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect browser_tests changes
|
||||
id: changed-paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
browser_tests:
|
||||
- 'browser_tests/**'
|
||||
|
||||
- name: Verify lint and format
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm stylelint
|
||||
pnpm format:check
|
||||
pnpm knip
|
||||
|
||||
- name: Typecheck browser tests
|
||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||
shell: bash
|
||||
run: pnpm typecheck:browser
|
||||
29
.github/workflows/ci-lint-format-queue.yaml
vendored
Normal file
29
.github/workflows/ci-lint-format-queue.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Description: Lint and format verification for GitHub merge queue runs.
|
||||
# Paired with ci-lint-format.yaml — workflow name and job name must match
|
||||
# so branch protection resolves a single required check in both the
|
||||
# pull_request and merge_group contexts. This file runs verify-only steps
|
||||
# with a read-only token; auto-fix and PR comments live in the PR workflow.
|
||||
name: 'CI: Lint Format'
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-and-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout merge group ref
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Verify lint and format
|
||||
uses: ./.github/actions/lint-format-verify
|
||||
25
.github/workflows/ci-lint-format.yaml
vendored
25
.github/workflows/ci-lint-format.yaml
vendored
@@ -1,4 +1,7 @@
|
||||
# Description: Linting and code formatting validation for pull requests
|
||||
# Description: Linting and code formatting validation for pull requests.
|
||||
# Paired with ci-lint-format-queue.yaml - workflow name and job name must
|
||||
# match so branch protection resolves a single required check in both the
|
||||
# pull_request and merge_group contexts.
|
||||
name: 'CI: Lint Format'
|
||||
|
||||
on:
|
||||
@@ -26,14 +29,6 @@ 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
|
||||
|
||||
@@ -77,16 +72,8 @@ jobs:
|
||||
echo "See CONTRIBUTING.md for more details."
|
||||
exit 1
|
||||
|
||||
- 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: Verify lint and format
|
||||
uses: ./.github/actions/lint-format-verify
|
||||
|
||||
- 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
|
||||
|
||||
1
.github/workflows/ci-tests-e2e.yaml
vendored
1
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/ci-tests-unit.yaml
vendored
1
.github/workflows/ci-tests-unit.yaml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
@@ -116,13 +117,60 @@
|
||||
},
|
||||
{
|
||||
"files": ["browser_tests/**/*.ts"],
|
||||
"jsPlugins": ["eslint-plugin-playwright"],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"unicorn/no-empty-file": "error"
|
||||
"unicorn/no-empty-file": "error",
|
||||
"playwright/consistent-spacing-between-blocks": "error",
|
||||
"playwright/expect-expect": [
|
||||
"error",
|
||||
{
|
||||
"assertFunctionNames": [
|
||||
"recordMeasurement",
|
||||
"logMeasurement",
|
||||
"builderSaveAs"
|
||||
],
|
||||
"assertFunctionPatterns": [
|
||||
"^expect",
|
||||
"^assert",
|
||||
"^verify",
|
||||
"^searchAndExpect",
|
||||
"waitForOpen",
|
||||
"waitForClosed",
|
||||
"waitForRequest"
|
||||
]
|
||||
}
|
||||
],
|
||||
"playwright/max-nested-describe": "error",
|
||||
"playwright/no-duplicate-hooks": "error",
|
||||
"playwright/no-element-handle": "error",
|
||||
"playwright/no-eval": "error",
|
||||
"playwright/no-focused-test": "error",
|
||||
"playwright/no-force-option": "error",
|
||||
"playwright/no-networkidle": "error",
|
||||
"playwright/no-page-pause": "error",
|
||||
"playwright/no-skipped-test": "error",
|
||||
"playwright/no-unsafe-references": "error",
|
||||
"playwright/no-unused-locators": "error",
|
||||
"playwright/no-useless-await": "error",
|
||||
"playwright/no-useless-not": "error",
|
||||
"playwright/no-wait-for-navigation": "error",
|
||||
"playwright/no-wait-for-selector": "error",
|
||||
"playwright/no-wait-for-timeout": "error",
|
||||
"playwright/prefer-hooks-on-top": "error",
|
||||
"playwright/prefer-locator": "error",
|
||||
"playwright/prefer-to-have-count": "error",
|
||||
"playwright/prefer-to-have-length": "error",
|
||||
"playwright/prefer-web-first-assertions": "error",
|
||||
"playwright/prefer-native-locators": "error",
|
||||
"playwright/require-to-pass-timeout": "error",
|
||||
"playwright/valid-expect": "error",
|
||||
"playwright/valid-expect-in-promise": "error",
|
||||
"playwright/valid-title": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -22,7 +22,8 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
- Entry Point: `src/main.ts`.
|
||||
- Tests:
|
||||
- unit/component in `src/**/*.test.ts`
|
||||
- E2E (Playwright) in `browser_tests/**/*.spec.ts`
|
||||
- browser helper/unit tests in `browser_tests/**/*.test.ts`
|
||||
- E2E (Playwright) in `browser_tests/tests/**/*.spec.ts`
|
||||
- Public assets: `public/`
|
||||
- Build output: `dist/`
|
||||
- Configs
|
||||
@@ -318,6 +319,9 @@ When referencing Comfy-Org repos:
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
|
||||
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
|
||||
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const features = computed(() => [
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs tracking-widest text-brand-yellow uppercase"
|
||||
>
|
||||
{{ t('academy.badge', locale) }}
|
||||
</span>
|
||||
|
||||
@@ -40,7 +40,7 @@ const steps = computed(() => [
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
class="absolute top-8 right-0 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
|
||||
@@ -31,11 +31,11 @@ const ctaButtons = computed(() => [
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
class="size-64 rounded-full border-40 border-brand-yellow md:h-112 md:w-md md:border-64 lg:h-144 lg:w-xl lg:border-80"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
class="absolute top-1/2 right-0 h-32 w-24 translate-x-1/2 -translate-y-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@ const ctaButtons = computed(() => [
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
class="text-5xl/tight font-bold tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
{{ t('hero.headline', locale) }}
|
||||
</h1>
|
||||
|
||||
@@ -17,7 +17,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
{{ t('manifesto.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg/relaxed text-smoke-700">
|
||||
{{ t('manifesto.body', locale) }}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ const features = computed(() => [
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
class="flex size-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
class="ml-1 size-0 border-y-8 border-l-14 border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">
|
||||
@@ -54,7 +54,7 @@ const features = computed(() => [
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
class="size-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
|
||||
@@ -32,7 +32,7 @@ const metrics = computed(() => [
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
class="text-center text-xs font-medium tracking-widest text-smoke-700 uppercase"
|
||||
>
|
||||
{{ t('social.heading', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -90,7 +90,7 @@ const filteredTestimonials = computed(() => {
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
<blockquote class="text-base text-white italic">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ const activeCategory = ref(0)
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
class="aspect-2/3 rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<div class="flex flex-col items-center text-center lg:flex-2">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('useCase.heading', locale) }}
|
||||
</h2>
|
||||
@@ -70,7 +70,7 @@ const activeCategory = ref(0)
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
class="aspect-2/3 rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ const pillars = computed(() => [
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
class="flex size-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,39 @@ browser_tests/
|
||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||
- **`fixtures/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).
|
||||
|
||||
@@ -108,6 +108,11 @@ Browser tests in this project follow a specific organization pattern:
|
||||
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
|
||||
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
|
||||
|
||||
- **Helper Unit Tests**: Colocated alongside browser-test helpers and fixtures
|
||||
- Use `.test.ts`
|
||||
- Run under Vitest, not Playwright
|
||||
- Do not place them under `browser_tests/tests/`, which is reserved for Playwright `*.spec.ts`
|
||||
|
||||
- **Utilities**: Located in `utils/` - Common utility functions
|
||||
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
|
||||
|
||||
|
||||
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal file
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 50],
|
||||
"pos": [450, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
{
|
||||
"last_node_id": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
@@ -15,7 +35,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal file
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal file
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
50
browser_tests/assets/widgets/image_crop_widget.json
Normal file
50
browser_tests/assets/widgets/image_crop_widget.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
100
browser_tests/assets/widgets/image_crop_with_source.json
Normal file
100
browser_tests/assets/widgets/image_crop_with_source.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [450, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"width": 100,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [900, 50],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -26,11 +26,10 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
static defaultSteps = 5
|
||||
static defaultOptions: DragOptions = { steps: ComfyMouse.defaultSteps }
|
||||
|
||||
constructor(readonly comfyPage: ComfyPage) {}
|
||||
readonly mouse: Mouse
|
||||
|
||||
/** The normal Playwright {@link Mouse} property from {@link ComfyPage.page}. */
|
||||
get mouse() {
|
||||
return this.comfyPage.page.mouse
|
||||
constructor(readonly comfyPage: ComfyPage) {
|
||||
this.mouse = comfyPage.page.mouse
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
|
||||
@@ -29,9 +29,6 @@ import {
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
@@ -73,15 +70,13 @@ 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)
|
||||
}
|
||||
|
||||
get buttons() {
|
||||
return this.sideToolbar.locator('.side-bar-button')
|
||||
this.buttons = this.sideToolbar.locator('.side-bar-button')
|
||||
}
|
||||
|
||||
get modelLibraryTab() {
|
||||
@@ -179,10 +174,9 @@ export class ComfyPage {
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
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[] = []
|
||||
@@ -225,22 +219,17 @@ 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)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.assetApi = createAssetHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
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)
|
||||
@@ -325,7 +314,7 @@ export class ComfyPage {
|
||||
// window.app.extensionManager => GraphView ready
|
||||
window.app && window.app.extensionManager
|
||||
)
|
||||
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
|
||||
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -375,7 +364,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async closeMenu() {
|
||||
await this.page.click('button.comfy-close-menu-btn')
|
||||
await this.page.locator('button.comfy-close-menu-btn').click()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -418,6 +407,8 @@ export const comfyPageFixture = base.extend<{
|
||||
const userId = await comfyPage.setupUser(username)
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
const isVueNodes = testInfo.tags.includes('@vue-nodes')
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
@@ -440,7 +431,8 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
// Disable errors tab to prevent missing model detection from
|
||||
// rendering error indicators on nodes during unrelated tests.
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false,
|
||||
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -452,13 +444,16 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
if (isVueNodes) {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
|
||||
const needsPerf =
|
||||
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')
|
||||
if (needsPerf) await comfyPage.perf.init()
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, 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
|
||||
) {}
|
||||
|
||||
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')
|
||||
) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,20 @@ 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
|
||||
*/
|
||||
get nodes(): Locator {
|
||||
return this.page.locator('[data-node-id]')
|
||||
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'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,13 +30,6 @@ export class VueNodeHelpers {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
get selectedNodes(): Locator {
|
||||
return this.page.locator('[data-node-id].outline-node-component-outline')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
@@ -37,7 +37,7 @@ export class VueNodeHelpers {
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
|
||||
has: this.page.getByTestId('node-title').filter({ hasText: title })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export class VueNodeHelpers {
|
||||
expectedCount
|
||||
)
|
||||
} else {
|
||||
await this.page.waitForSelector('[data-node-id]')
|
||||
await this.page.locator('[data-node-id]').first().waitFor()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
export const assetApiFixture = base.extend<{
|
||||
assetApi: AssetHelper
|
||||
}>({
|
||||
assetApi: async ({ page }, use) => {
|
||||
const assetApi = createAssetHelper(page)
|
||||
|
||||
await use(assetApi)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
}
|
||||
})
|
||||
15
browser_tests/fixtures/assetScenarioFixture.ts
Normal file
15
browser_tests/fixtures/assetScenarioFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
|
||||
|
||||
export const assetScenarioFixture = base.extend<{
|
||||
assetScenario: AssetScenarioHelper
|
||||
}>({
|
||||
assetScenario: async ({ page }, use) => {
|
||||
const assetScenario = new AssetScenarioHelper(page)
|
||||
|
||||
await use(assetScenario)
|
||||
|
||||
await assetScenario.clear()
|
||||
}
|
||||
})
|
||||
@@ -3,13 +3,11 @@ 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')
|
||||
}
|
||||
|
||||
get header() {
|
||||
return this.root
|
||||
this.header = this.root
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Add node filter condition' })
|
||||
}
|
||||
@@ -41,6 +39,8 @@ 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,13 +50,15 @@ 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 }
|
||||
@@ -78,12 +80,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -2,18 +2,14 @@ import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly primeVueMenu: Locator
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
|
||||
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')
|
||||
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')
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
|
||||
97
browser_tests/fixtures/components/OutputHistory.ts
Normal file
97
browser_tests/fixtures/components/OutputHistory.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const ids = TestIds.outputHistory
|
||||
|
||||
export class OutputHistoryComponent {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
get outputs(): Locator {
|
||||
return this.page.getByTestId(ids.outputs)
|
||||
}
|
||||
|
||||
get welcome(): Locator {
|
||||
return this.page.getByTestId(ids.welcome)
|
||||
}
|
||||
|
||||
get outputInfo(): Locator {
|
||||
return this.page.getByTestId(ids.outputInfo)
|
||||
}
|
||||
|
||||
get activeQueue(): Locator {
|
||||
return this.page.getByTestId(ids.activeQueue)
|
||||
}
|
||||
|
||||
get queueBadge(): Locator {
|
||||
return this.page.getByTestId(ids.queueBadge)
|
||||
}
|
||||
|
||||
get inProgressItems(): Locator {
|
||||
return this.page.getByTestId(ids.inProgressItem)
|
||||
}
|
||||
|
||||
get historyItems(): Locator {
|
||||
return this.page.getByTestId(ids.historyItem)
|
||||
}
|
||||
|
||||
get skeletons(): Locator {
|
||||
return this.page.getByTestId(ids.skeleton)
|
||||
}
|
||||
|
||||
get latentPreviews(): Locator {
|
||||
return this.page.getByTestId(ids.latentPreview)
|
||||
}
|
||||
|
||||
get imageOutputs(): Locator {
|
||||
return this.page.getByTestId(ids.imageOutput)
|
||||
}
|
||||
|
||||
get videoOutputs(): Locator {
|
||||
return this.page.getByTestId(ids.videoOutput)
|
||||
}
|
||||
|
||||
/** The currently selected (checked) in-progress item. */
|
||||
get selectedInProgressItem(): Locator {
|
||||
return this.page.locator(
|
||||
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
|
||||
)
|
||||
}
|
||||
|
||||
/** The currently selected (checked) history item. */
|
||||
get selectedHistoryItem(): Locator {
|
||||
return this.page.locator(
|
||||
`[data-testid="${ids.historyItem}"][data-state="checked"]`
|
||||
)
|
||||
}
|
||||
|
||||
/** The header-level progress bar. */
|
||||
get headerProgressBar(): Locator {
|
||||
return this.page.getByTestId(ids.headerProgressBar)
|
||||
}
|
||||
|
||||
/** The in-progress item's progress bar (inside the thumbnail). */
|
||||
get itemProgressBar(): Locator {
|
||||
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
|
||||
}
|
||||
|
||||
/** Overall progress in the header bar. */
|
||||
get headerOverallProgress(): Locator {
|
||||
return this.headerProgressBar.getByTestId(ids.progressOverall)
|
||||
}
|
||||
|
||||
/** Node progress in the header bar. */
|
||||
get headerNodeProgress(): Locator {
|
||||
return this.headerProgressBar.getByTestId(ids.progressNode)
|
||||
}
|
||||
|
||||
/** Overall progress in the in-progress item bar. */
|
||||
get itemOverallProgress(): Locator {
|
||||
return this.itemProgressBar.getByTestId(ids.progressOverall)
|
||||
}
|
||||
|
||||
/** Node progress in the in-progress item bar. */
|
||||
get itemNodeProgress(): Locator {
|
||||
return this.itemProgressBar.getByTestId(ids.progressNode)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { 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() {
|
||||
@@ -36,27 +43,15 @@ export class SettingDialog extends BaseDialog {
|
||||
await settingInputDiv.locator('input').click()
|
||||
}
|
||||
|
||||
get searchBox() {
|
||||
return this.root.getByPlaceholder(/Search/)
|
||||
}
|
||||
|
||||
get categories() {
|
||||
return this.root.locator('nav').getByRole('button')
|
||||
}
|
||||
|
||||
category(name: string) {
|
||||
return this.root.locator('nav').getByRole('button', { name })
|
||||
}
|
||||
|
||||
get contentArea() {
|
||||
return this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||
name: 'About'
|
||||
})
|
||||
await aboutButton.click()
|
||||
await this.page.waitForSelector('.about-container')
|
||||
await this.page.locator('.about-container').waitFor()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,16 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly tabId: string
|
||||
) {}
|
||||
|
||||
get tabButton() {
|
||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||
}
|
||||
|
||||
get selectedTabButton() {
|
||||
return this.page.locator(
|
||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||
) {
|
||||
this.tabButton = page.locator(`.${tabId}-tab-button`)
|
||||
this.selectedTabButton = page.locator(
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,28 +33,19 @@ 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')
|
||||
}
|
||||
|
||||
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')
|
||||
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')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
@@ -101,34 +90,25 @@ 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')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search...')
|
||||
}
|
||||
|
||||
get sidebarContent() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
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' })
|
||||
}
|
||||
|
||||
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 })
|
||||
@@ -154,12 +134,15 @@ 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')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.root = page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.activeWorkflowLabel = this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
@@ -168,12 +151,6 @@ 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()
|
||||
}
|
||||
@@ -228,36 +205,27 @@ 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')
|
||||
}
|
||||
|
||||
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')
|
||||
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')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
@@ -281,141 +249,178 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly generatedTab: Locator
|
||||
public readonly importedTab: Locator
|
||||
public readonly emptyStateMessage: Locator
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
public readonly listViewOption: Locator
|
||||
public readonly gridViewOption: Locator
|
||||
public readonly backToAssetsButton: Locator
|
||||
public readonly copyJobIdButton: Locator
|
||||
public readonly previewDialog: Locator
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
public readonly assetCards: Locator
|
||||
public readonly selectedCards: Locator
|
||||
public readonly listViewItems: Locator
|
||||
public readonly selectionFooter: Locator
|
||||
public readonly selectionCountButton: Locator
|
||||
public readonly deselectAllButton: Locator
|
||||
public readonly deleteSelectedButton: Locator
|
||||
public readonly downloadSelectedButton: Locator
|
||||
public readonly skeletonLoaders: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- 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(
|
||||
this.root = page.locator('.sidebar-content-container')
|
||||
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
|
||||
this.importedTab = page.getByRole('tab', { name: 'Imported' })
|
||||
this.emptyStateMessage = page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
this.searchInput = this.root.getByPlaceholder(/Search Assets/i)
|
||||
this.settingsButton = this.root.getByLabel('View settings')
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.backToAssetsButton = page.getByRole('button', {
|
||||
name: 'Back to all assets'
|
||||
})
|
||||
this.copyJobIdButton = page.getByRole('button', {
|
||||
name: 'Copy job ID'
|
||||
})
|
||||
this.previewDialog = page.getByRole('dialog', { name: 'Gallery' })
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.assetCards = this.root
|
||||
.getByRole('button')
|
||||
.and(this.root.locator('[data-selected]'))
|
||||
this.selectedCards = this.root.locator('[data-selected="true"]')
|
||||
this.listViewItems = this.root.getByRole('button', { name: /asset$/i })
|
||||
this.selectionFooter = this.root.locator('..').getByRole('toolbar', {
|
||||
name: 'Selected asset actions'
|
||||
})
|
||||
this.selectionCountButton = this.root
|
||||
.getByRole('button', { name: /Assets Selected:/ })
|
||||
.or(page.getByText(/Assets Selected: \d+/))
|
||||
.first()
|
||||
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.skeletonLoaders = this.root.locator('.animate-pulse')
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
previewImage(filename: string) {
|
||||
return this.previewDialog.getByRole('img', { name: filename })
|
||||
}
|
||||
|
||||
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]')
|
||||
asset(name: string) {
|
||||
return this.getAssetCardByName(name)
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
return this.assetCards.filter({ hasText: name }).first()
|
||||
}
|
||||
|
||||
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')
|
||||
contextMenuAction(label: string) {
|
||||
return this.contextMenuItem(label)
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
async showGenerated() {
|
||||
await this.switchToGenerated()
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
async showImported() {
|
||||
await this.switchToImported()
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.searchInput.fill(query)
|
||||
}
|
||||
|
||||
async switchToListView() {
|
||||
await this.openSettingsMenu()
|
||||
await this.listViewOption.click()
|
||||
}
|
||||
|
||||
async switchToGridView() {
|
||||
await this.openSettingsMenu()
|
||||
await this.gridViewOption.click()
|
||||
}
|
||||
|
||||
async openContextMenuForAsset(name: string) {
|
||||
await this.asset(name).click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async runContextMenuAction(assetName: string, actionName: string) {
|
||||
await this.openContextMenuForAsset(assetName)
|
||||
await this.contextMenuAction(actionName).click()
|
||||
}
|
||||
|
||||
async openAssetPreview(name: string) {
|
||||
const asset = this.asset(name)
|
||||
await asset.hover()
|
||||
|
||||
const zoomButton = asset.getByLabel('Zoom in')
|
||||
if (await zoomButton.isVisible().catch(() => false)) {
|
||||
await zoomButton.click()
|
||||
return
|
||||
}
|
||||
|
||||
await asset.dblclick()
|
||||
}
|
||||
|
||||
async openOutputFolder(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await this.backToAssetsButton.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async toggleStack(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
}
|
||||
|
||||
async selectAssets(names: string[]) {
|
||||
if (names.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.asset(names[0]).click()
|
||||
|
||||
for (const name of names.slice(1)) {
|
||||
await this.asset(name).click({
|
||||
modifiers: ['ControlOrMeta']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.root.waitFor({ state: 'visible' })
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
@@ -423,28 +428,24 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
await btn.click().catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
.toHaveCount(0, { timeout: 5000 })
|
||||
.toHaveCount(0)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async switchToImported() {
|
||||
await this.dismissToasts()
|
||||
await this.importedTab.click()
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
}
|
||||
|
||||
async switchToGenerated() {
|
||||
await this.dismissToasts()
|
||||
await this.generatedTab.click()
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
}
|
||||
|
||||
async openSettingsMenu() {
|
||||
@@ -467,7 +468,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
|
||||
async waitForAssets(count?: number) {
|
||||
if (count !== undefined) {
|
||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
||||
await expect(this.assetCards).toHaveCount(count)
|
||||
} else {
|
||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ 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)
|
||||
@@ -22,6 +33,22 @@ 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() {
|
||||
@@ -30,48 +57,4 @@ 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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ 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[]> {
|
||||
@@ -50,10 +52,6 @@ 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}")`)
|
||||
@@ -73,7 +71,7 @@ export class Topbar {
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.hover()
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
await tab.locator('.close-button').click()
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
@@ -107,7 +105,7 @@ export class Topbar {
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
// Wait for the dialog to close.
|
||||
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
||||
await this.getSaveDialog().waitFor({ state: 'hidden' })
|
||||
|
||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||
// If so, return early to let the test handle the confirmation
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
|
||||
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
||||
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
@@ -14,14 +15,66 @@ export class AppModeHelper {
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
public readonly welcome: Locator
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
public readonly emptyWorkflowText: Locator
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
public readonly buildAppButton: Locator
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
public readonly backToWorkflowButton: Locator
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
)
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
TestIds.appMode.emptyWorkflow
|
||||
)
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.backToWorkflowButton = this.page.getByTestId(
|
||||
TestIds.appMode.backToWorkflow
|
||||
)
|
||||
this.loadTemplateButton = this.page.getByTestId(
|
||||
TestIds.appMode.loadTemplate
|
||||
)
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -93,61 +146,6 @@ 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").
|
||||
|
||||
133
browser_tests/fixtures/helpers/AssetScenarioHelper.test.ts
Normal file
133
browser_tests/fixtures/helpers/AssetScenarioHelper.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
|
||||
type RouteHandler = (route: Route) => Promise<void>
|
||||
|
||||
type RegisteredRoute = {
|
||||
pattern: string | RegExp
|
||||
handler: RouteHandler
|
||||
}
|
||||
|
||||
type PageStub = Pick<Page, 'route' | 'unroute'>
|
||||
|
||||
type FulfillOptions = NonNullable<Parameters<Route['fulfill']>[0]>
|
||||
|
||||
function createPageStub(): {
|
||||
page: PageStub
|
||||
routes: RegisteredRoute[]
|
||||
} {
|
||||
const routes: RegisteredRoute[] = []
|
||||
const page = {
|
||||
route: vi.fn(async (pattern: string | RegExp, handler: RouteHandler) => {
|
||||
routes.push({ pattern, handler })
|
||||
}),
|
||||
unroute: vi.fn(async () => {})
|
||||
} satisfies PageStub
|
||||
|
||||
return { page, routes }
|
||||
}
|
||||
|
||||
function getRouteHandler(
|
||||
routes: RegisteredRoute[],
|
||||
matcher: (pattern: string | RegExp) => boolean
|
||||
): RouteHandler {
|
||||
const registeredRoute = routes.find(({ pattern }) => matcher(pattern))
|
||||
|
||||
if (!registeredRoute) {
|
||||
throw new Error('Expected route handler to be registered')
|
||||
}
|
||||
|
||||
return registeredRoute.handler
|
||||
}
|
||||
|
||||
function createRouteInvocation(url: string): {
|
||||
route: Route
|
||||
getFulfilled: () => FulfillOptions | undefined
|
||||
} {
|
||||
let fulfilled: FulfillOptions | undefined
|
||||
|
||||
const route = {
|
||||
request: () =>
|
||||
({
|
||||
url: () => url
|
||||
}) as ReturnType<Route['request']>,
|
||||
fulfill: vi.fn(async (options?: FulfillOptions) => {
|
||||
if (!options) {
|
||||
throw new Error('Expected route to be fulfilled with options')
|
||||
}
|
||||
|
||||
fulfilled = options
|
||||
})
|
||||
} satisfies Pick<Route, 'request' | 'fulfill'>
|
||||
|
||||
return {
|
||||
route: route as unknown as Route,
|
||||
getFulfilled: () => fulfilled
|
||||
}
|
||||
}
|
||||
|
||||
function bodyToText(body: FulfillOptions['body']): string {
|
||||
if (body instanceof Uint8Array) {
|
||||
return Buffer.from(body).toString('utf-8')
|
||||
}
|
||||
|
||||
return `${body ?? ''}`
|
||||
}
|
||||
|
||||
async function invokeViewRoute(
|
||||
handler: RouteHandler,
|
||||
url: string
|
||||
): Promise<string> {
|
||||
const invocation = createRouteInvocation(url)
|
||||
|
||||
await handler(invocation.route)
|
||||
|
||||
const fulfilled = invocation.getFulfilled()
|
||||
expect(fulfilled).toBeDefined()
|
||||
|
||||
return bodyToText(fulfilled?.body)
|
||||
}
|
||||
|
||||
describe('AssetScenarioHelper', () => {
|
||||
it('serves generated outputs and imported files through the view route', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const helper = new AssetScenarioHelper(page as unknown as Page)
|
||||
|
||||
await helper.seedGeneratedHistory([
|
||||
createMockJob({
|
||||
id: 'job-generated',
|
||||
preview_output: {
|
||||
filename: 'generated.json',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
await helper.seedImportedFiles(['imported.txt'])
|
||||
|
||||
const viewRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
(pattern) =>
|
||||
pattern instanceof RegExp && /api\\\/view/.test(pattern.source)
|
||||
)
|
||||
|
||||
await expect(
|
||||
invokeViewRoute(
|
||||
viewRouteHandler,
|
||||
'http://localhost/api/view?filename=generated.json&type=output&subfolder='
|
||||
)
|
||||
).resolves.toBe(JSON.stringify({ mocked: true }, null, 2))
|
||||
|
||||
await expect(
|
||||
invokeViewRoute(
|
||||
viewRouteHandler,
|
||||
'http://localhost/api/view?filename=imported.txt&type=input&subfolder='
|
||||
)
|
||||
).resolves.toBe('mocked asset content')
|
||||
})
|
||||
})
|
||||
275
browser_tests/fixtures/helpers/AssetScenarioHelper.ts
Normal file
275
browser_tests/fixtures/helpers/AssetScenarioHelper.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture,
|
||||
ImportedAssetFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import {
|
||||
buildSeededFileKey,
|
||||
buildSeededFiles,
|
||||
defaultFileFor
|
||||
} from '@e2e/fixtures/helpers/seededAssetFiles'
|
||||
import type { SeededAssetFile } from '@e2e/fixtures/helpers/seededAssetFiles'
|
||||
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
|
||||
const DEFAULT_FIXTURE_CREATE_TIME = Date.UTC(2024, 0, 1, 0, 0, 0)
|
||||
|
||||
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
|
||||
filename?: string
|
||||
subfolder?: string
|
||||
type?: GeneratedOutputFixture['type']
|
||||
nodeId: string
|
||||
mediaType?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
function normalizeOutputFixture(
|
||||
output: GeneratedOutputFixture
|
||||
): GeneratedOutputFixture {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
return {
|
||||
mediaType: 'images',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
...output,
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputFilename(baseFilename: string, index: number): string {
|
||||
if (index === 0) {
|
||||
return baseFilename
|
||||
}
|
||||
|
||||
const extensionIndex = baseFilename.lastIndexOf('.')
|
||||
if (extensionIndex === -1) {
|
||||
return `${baseFilename}-${index + 1}`
|
||||
}
|
||||
|
||||
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
|
||||
}
|
||||
|
||||
function getPreviewOutput(
|
||||
previewOutput: JobEntry['preview_output'] | undefined
|
||||
): MockPreviewOutput | undefined {
|
||||
return previewOutput as MockPreviewOutput | undefined
|
||||
}
|
||||
|
||||
function outputsFromJobEntry(
|
||||
job: JobEntry
|
||||
): [GeneratedOutputFixture, ...GeneratedOutputFixture[]] {
|
||||
const previewOutput = getPreviewOutput(job.preview_output)
|
||||
const outputCount = Math.max(job.outputs_count ?? 1, 1)
|
||||
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
|
||||
const mediaType: GeneratedOutputFixture['mediaType'] =
|
||||
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
|
||||
? previewOutput.mediaType
|
||||
: 'images'
|
||||
const outputs = Array.from({ length: outputCount }, (_, index) => ({
|
||||
filename: createOutputFilename(baseFilename, index),
|
||||
displayName: index === 0 ? previewOutput?.display_name : undefined,
|
||||
mediaType,
|
||||
subfolder: previewOutput?.subfolder ?? '',
|
||||
type: previewOutput?.type ?? 'output'
|
||||
}))
|
||||
|
||||
return [outputs[0], ...outputs.slice(1)]
|
||||
}
|
||||
|
||||
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobFixture {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
outputs: outputsFromJobEntry(job),
|
||||
createTime: job.create_time,
|
||||
executionStartTime: job.execution_start_time,
|
||||
executionEndTime: job.execution_end_time,
|
||||
workflowId: job.workflow_id
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeededJob(job: GeneratedJobFixture) {
|
||||
const outputs = job.outputs.map(normalizeOutputFixture)
|
||||
const preview = outputs[0]
|
||||
const createTime =
|
||||
job.createTime ??
|
||||
(job.createdAt
|
||||
? new Date(job.createdAt).getTime()
|
||||
: DEFAULT_FIXTURE_CREATE_TIME)
|
||||
const executionStartTime = job.executionStartTime ?? createTime
|
||||
const executionEndTime = job.executionEndTime ?? createTime + 2_000
|
||||
|
||||
const listItem: JobEntry = {
|
||||
id: job.jobId,
|
||||
status: job.status ?? 'completed',
|
||||
create_time: createTime,
|
||||
execution_start_time: executionStartTime,
|
||||
execution_end_time: executionEndTime,
|
||||
preview_output: {
|
||||
filename: preview.filename,
|
||||
subfolder: preview.subfolder ?? '',
|
||||
type: preview.type ?? 'output',
|
||||
nodeId: job.nodeId ?? '5',
|
||||
mediaType: preview.mediaType ?? 'images',
|
||||
display_name: preview.displayName
|
||||
},
|
||||
outputs_count: outputs.length,
|
||||
...(job.workflowId ? { workflow_id: job.workflowId } : {})
|
||||
}
|
||||
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
workflow: job.workflow,
|
||||
outputs: buildMockJobOutputs(job, outputs),
|
||||
update_time: executionEndTime
|
||||
}
|
||||
|
||||
return { listItem, detail }
|
||||
}
|
||||
|
||||
export class AssetScenarioHelper {
|
||||
private readonly jobsBackend: InMemoryJobsBackend
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private generatedJobs: GeneratedJobFixture[] = []
|
||||
private importedFiles: ImportedAssetFixture[] = []
|
||||
private seededFiles = new Map<string, SeededAssetFile>()
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.jobsBackend = new InMemoryJobsBackend(page)
|
||||
}
|
||||
|
||||
async seedGeneratedHistory(jobs: readonly JobEntry[]): Promise<void> {
|
||||
await this.seed({
|
||||
generated: jobs.map(generatedJobFromJobEntry),
|
||||
imported: this.importedFiles
|
||||
})
|
||||
}
|
||||
|
||||
async seedImportedFiles(files: readonly string[]): Promise<void> {
|
||||
await this.seed({
|
||||
generated: this.generatedJobs,
|
||||
imported: files.map((name) => ({ name }))
|
||||
})
|
||||
}
|
||||
|
||||
async seedEmptyState(): Promise<void> {
|
||||
await this.seed({ generated: [], imported: [] })
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
this.seededFiles.clear()
|
||||
|
||||
await this.jobsBackend.clear()
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.viewRouteHandler) {
|
||||
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
|
||||
this.viewRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async seed({
|
||||
generated,
|
||||
imported
|
||||
}: {
|
||||
generated: GeneratedJobFixture[]
|
||||
imported: ImportedAssetFixture[]
|
||||
}): Promise<void> {
|
||||
this.generatedJobs = [...generated]
|
||||
this.importedFiles = [...imported]
|
||||
this.seededFiles = buildSeededFiles({
|
||||
generated: this.generatedJobs,
|
||||
imported: this.importedFiles
|
||||
})
|
||||
|
||||
await this.jobsBackend.seed(this.generatedJobs.map(buildSeededJob))
|
||||
await this.ensureInputFilesRoute()
|
||||
await this.ensureViewRoute()
|
||||
}
|
||||
|
||||
private async ensureInputFilesRoute(): Promise<void> {
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
private async ensureViewRoute(): Promise<void> {
|
||||
if (this.viewRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.viewRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename')
|
||||
const type = url.searchParams.get('type') ?? 'output'
|
||||
const subfolder = url.searchParams.get('subfolder') ?? ''
|
||||
|
||||
if (!filename) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Missing filename' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const seededFile =
|
||||
this.seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
})
|
||||
) ?? defaultFileFor(filename)
|
||||
|
||||
if (seededFile.filePath) {
|
||||
const body = await readFile(seededFile.filePath)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body: seededFile.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(viewRoutePattern, this.viewRouteHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now()
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private importedFiles: string[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
|
||||
this.generatedJobs = [...jobs]
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = [...this.generatedJobs]
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the POST /api/history endpoint used for deleting history items.
|
||||
* On receiving a `{ delete: [id] }` payload, removes matching jobs from
|
||||
* the in-memory mock state so subsequent /api/jobs fetches reflect the
|
||||
* deletion.
|
||||
*/
|
||||
async mockDeleteHistory(): Promise<void> {
|
||||
if (this.deleteHistoryRouteHandler) return
|
||||
|
||||
this.deleteHistoryRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const body = request.postDataJSON() as { delete?: string[] }
|
||||
if (body.delete) {
|
||||
const idsToRemove = new Set(body.delete)
|
||||
this.generatedJobs = this.generatedJobs.filter(
|
||||
(job) => !idsToRemove.has(job.id)
|
||||
)
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 200, body: '{}' })
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.deleteHistoryRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockOutputHistory([])
|
||||
await this.mockInputFiles([])
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.deleteHistoryRouteHandler) {
|
||||
await this.page.unroute(
|
||||
historyRoutePattern,
|
||||
this.deleteHistoryRouteHandler
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,48 +4,32 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class BuilderFooterHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
public readonly nav: Locator
|
||||
public readonly exitButton: Locator
|
||||
public readonly nextButton: Locator
|
||||
public readonly backButton: Locator
|
||||
public readonly saveButton: Locator
|
||||
public readonly saveGroup: Locator
|
||||
public readonly saveAsButton: Locator
|
||||
public readonly saveAsChevron: Locator
|
||||
public readonly opensAsPopover: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.nav = this.page.getByTestId(TestIds.builder.footerNav)
|
||||
this.exitButton = this.buttonByName('Exit app builder')
|
||||
this.nextButton = this.buttonByName('Next')
|
||||
this.backButton = this.buttonByName('Back')
|
||||
this.saveButton = this.page.getByTestId(TestIds.builder.saveButton)
|
||||
this.saveGroup = this.page.getByTestId(TestIds.builder.saveGroup)
|
||||
this.saveAsButton = this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
this.saveAsChevron = this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
this.opensAsPopover = this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -3,73 +3,61 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
public readonly dialog: Locator
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
public readonly successDialog: Locator
|
||||
public readonly title: Locator
|
||||
public readonly radioGroup: Locator
|
||||
public readonly nameInput: Locator
|
||||
public readonly saveButton: Locator
|
||||
public readonly successMessage: Locator
|
||||
public readonly viewAppButton: Locator
|
||||
public readonly closeButton: Locator
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
public readonly dismissButton: Locator
|
||||
public readonly exitBuilderButton: Locator
|
||||
public readonly overwriteDialog: Locator
|
||||
public readonly overwriteButton: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.dialog = this.page.locator('[aria-labelledby="builder-save"]')
|
||||
this.successDialog = this.page.locator(
|
||||
'[aria-labelledby="builder-save-success"]'
|
||||
)
|
||||
this.title = this.dialog.getByText('Save as')
|
||||
this.radioGroup = this.dialog.getByRole('radiogroup')
|
||||
this.nameInput = this.dialog.getByRole('textbox')
|
||||
this.saveButton = this.dialog.getByRole('button', { name: 'Save' })
|
||||
this.successMessage = this.successDialog.getByText('Successfully saved')
|
||||
this.viewAppButton = this.successDialog.getByRole('button', {
|
||||
name: 'View app'
|
||||
})
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
this.overwriteDialog = this.page.getByRole('dialog', {
|
||||
name: 'Overwrite existing file?'
|
||||
})
|
||||
this.overwriteButton = this.overwriteDialog.getByRole('button', {
|
||||
name: 'Overwrite'
|
||||
})
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
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()
|
||||
|
||||
@@ -32,7 +32,20 @@ async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
public readonly inputItems: Locator
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
public readonly inputItemTitles: Locator
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
public readonly previewWidgetLabels: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.inputItems = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
this.inputItemTitles = this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
this.previewWidgetLabels = this.page.getByTestId(
|
||||
TestIds.builder.widgetLabel
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
@@ -43,12 +56,9 @@ export class BuilderSelectHelper {
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
return this.inputItems
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
has: this.inputItemTitles.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
@@ -141,6 +151,7 @@ export class BuilderSelectHelper {
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
@@ -150,38 +161,19 @@ export class BuilderSelectHelper {
|
||||
* Useful for asserting "Widget not visible" on disconnected inputs.
|
||||
*/
|
||||
getInputItemSubtitle(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
return this.inputItems
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
has: this.inputItemTitles.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) {
|
||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await dragByIndex(this.inputItems, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -208,6 +200,7 @@ export class BuilderSelectHelper {
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
)
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderStepsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
public readonly toolbar: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.toolbar = this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
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()
|
||||
|
||||
@@ -74,6 +74,51 @@ export class CanvasHelper {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a canvas-element-relative position to absolute page coordinates.
|
||||
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||
* cause Playwright's actionability check to fail on the canvas locator.
|
||||
*/
|
||||
private async toAbsolute(position: Position): Promise<Position> {
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
return { x: box.x + position.x, y: box.y + position.y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
|
||||
* Bypasses Playwright's actionability checks on the canvas locator, which
|
||||
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
|
||||
*/
|
||||
async mouseClickAt(
|
||||
position: Position,
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
}
|
||||
): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
const modifiers = options?.modifiers ?? []
|
||||
for (const mod of modifiers) await this.page.keyboard.down(mod)
|
||||
try {
|
||||
await this.page.mouse.click(abs.x, abs.y, {
|
||||
button: options?.button
|
||||
})
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click at canvas-element-relative coordinates using `page.mouse`.
|
||||
*/
|
||||
async mouseDblclickAt(position: Position): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
await this.page.mouse.dblclick(abs.x, abs.y)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickEmptySpace(): Promise<void> {
|
||||
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
||||
await this.nextFrame()
|
||||
|
||||
210
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
210
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
|
||||
/**
|
||||
* Helper for simulating prompt execution in e2e tests.
|
||||
*/
|
||||
export class ExecutionHelper {
|
||||
private jobCounter = 0
|
||||
private readonly completedJobs: JobEntry[] = []
|
||||
private readonly page: ComfyPage['page']
|
||||
private readonly command: ComfyPage['command']
|
||||
private readonly assetScenario: AssetScenarioHelper
|
||||
|
||||
constructor(
|
||||
comfyPage: ComfyPage,
|
||||
private readonly ws: WebSocketRoute
|
||||
) {
|
||||
this.page = comfyPage.page
|
||||
this.command = comfyPage.command
|
||||
this.assetScenario = new AssetScenarioHelper(comfyPage.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
|
||||
* the synthetic job ID.
|
||||
*
|
||||
* The app receives a valid PromptResponse so storeJob() fires
|
||||
* and registers the job against the active workflow path.
|
||||
*/
|
||||
async run(): Promise<string> {
|
||||
const jobId = `test-job-${++this.jobCounter}`
|
||||
|
||||
let fulfilled!: () => void
|
||||
const prompted = new Promise<void>((r) => {
|
||||
fulfilled = r
|
||||
})
|
||||
|
||||
await this.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
prompt_id: jobId,
|
||||
node_errors: {}
|
||||
})
|
||||
})
|
||||
fulfilled()
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await this.command.executeCommand('Comfy.QueuePrompt')
|
||||
await prompted
|
||||
|
||||
return jobId
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a binary `b_preview_with_metadata` WS message (type 4).
|
||||
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
|
||||
*/
|
||||
latentPreview(jobId: string, nodeId: string): void {
|
||||
const metadata = JSON.stringify({
|
||||
node_id: nodeId,
|
||||
display_node_id: nodeId,
|
||||
parent_node_id: nodeId,
|
||||
real_node_id: nodeId,
|
||||
prompt_id: jobId,
|
||||
image_type: 'image/png'
|
||||
})
|
||||
const metadataBytes = new TextEncoder().encode(metadata)
|
||||
|
||||
// 1x1 red PNG
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
)
|
||||
|
||||
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
|
||||
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
|
||||
const view = new DataView(buf)
|
||||
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
|
||||
view.setUint32(4, metadataBytes.length)
|
||||
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||
|
||||
this.ws.send(Buffer.from(buf))
|
||||
}
|
||||
|
||||
/** Send `execution_start` WS event. */
|
||||
executionStart(jobId: string): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_start',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `executing` WS event to signal which node is currently running. */
|
||||
executing(jobId: string, nodeId: string | null): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { prompt_id: jobId, node: nodeId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `executed` WS event with node output. */
|
||||
executed(
|
||||
jobId: string,
|
||||
nodeId: string,
|
||||
output: Record<string, unknown>
|
||||
): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executed',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
node: nodeId,
|
||||
display_node: nodeId,
|
||||
output
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_success` WS event. */
|
||||
executionSuccess(jobId: string): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_success',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_error` WS event. */
|
||||
executionError(jobId: string, nodeId: string, message: string): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_error',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
timestamp: Date.now(),
|
||||
node_id: nodeId,
|
||||
node_type: 'Unknown',
|
||||
exception_message: message,
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a job by adding it to mock history, sending execution_success,
|
||||
* and triggering a history refresh via a status event.
|
||||
*/
|
||||
async completeWithHistory(
|
||||
jobId: string,
|
||||
nodeId: string,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
this.completedJobs.push(
|
||||
createMockJob({
|
||||
id: jobId,
|
||||
preview_output: {
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await this.assetScenario.seedGeneratedHistory(this.completedJobs)
|
||||
this.executionSuccess(jobId)
|
||||
// Trigger queue/history refresh
|
||||
this.status(0)
|
||||
}
|
||||
|
||||
/** Send `status` WS event to update queue count. */
|
||||
status(queueRemaining: number): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
398
browser_tests/fixtures/helpers/InMemoryJobsBackend.test.ts
Normal file
398
browser_tests/fixtures/helpers/InMemoryJobsBackend.test.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
|
||||
import type { SeededJob } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
|
||||
|
||||
type RouteHandler = (route: Route) => Promise<void>
|
||||
|
||||
type RegisteredRoute = {
|
||||
pattern: string | RegExp
|
||||
handler: RouteHandler
|
||||
}
|
||||
|
||||
type PageStub = Pick<Page, 'route' | 'unroute'>
|
||||
|
||||
type FulfillOptions = NonNullable<Parameters<Route['fulfill']>[0]>
|
||||
|
||||
function createPageStub(): {
|
||||
page: PageStub
|
||||
routes: RegisteredRoute[]
|
||||
} {
|
||||
const routes: RegisteredRoute[] = []
|
||||
const page = {
|
||||
route: vi.fn(async (pattern: string | RegExp, handler: RouteHandler) => {
|
||||
routes.push({ pattern, handler })
|
||||
}),
|
||||
unroute: vi.fn(async () => {})
|
||||
} satisfies PageStub
|
||||
|
||||
return { page, routes }
|
||||
}
|
||||
|
||||
function createSeededJob({
|
||||
id,
|
||||
status = 'completed',
|
||||
createTime,
|
||||
executionStartTime = createTime,
|
||||
executionEndTime = createTime + 1_000,
|
||||
workflowId
|
||||
}: {
|
||||
id: string
|
||||
status?: JobEntry['status']
|
||||
createTime: number
|
||||
executionStartTime?: number
|
||||
executionEndTime?: number
|
||||
workflowId?: string
|
||||
}): SeededJob {
|
||||
const previewOutput = { filename: `${id}.png` }
|
||||
const terminalState =
|
||||
status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
|
||||
const listItem: JobEntry = {
|
||||
id,
|
||||
status,
|
||||
create_time: createTime,
|
||||
...(workflowId ? { workflow_id: workflowId } : {}),
|
||||
...(terminalState
|
||||
? {
|
||||
preview_output: previewOutput,
|
||||
outputs_count: 1,
|
||||
execution_start_time: executionStartTime,
|
||||
execution_end_time: executionEndTime
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
const detail: JobDetailResponse = {
|
||||
id,
|
||||
status,
|
||||
create_time: createTime,
|
||||
update_time: executionEndTime,
|
||||
...(workflowId ? { workflow_id: workflowId } : {}),
|
||||
...(terminalState
|
||||
? {
|
||||
preview_output: previewOutput,
|
||||
outputs_count: 1,
|
||||
outputs: {}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
return { listItem, detail }
|
||||
}
|
||||
|
||||
function getRouteHandler(routes: RegisteredRoute[], url: string): RouteHandler {
|
||||
const registeredRoute = routes.find(({ pattern }) =>
|
||||
typeof pattern === 'string' ? pattern === url : pattern.test(url)
|
||||
)
|
||||
|
||||
if (!registeredRoute) {
|
||||
throw new Error(`Expected route handler for ${url}`)
|
||||
}
|
||||
|
||||
return registeredRoute.handler
|
||||
}
|
||||
|
||||
function createRouteInvocation({
|
||||
url,
|
||||
method = 'POST',
|
||||
requestBody
|
||||
}: {
|
||||
url: string
|
||||
method?: string
|
||||
requestBody?: unknown
|
||||
}): {
|
||||
route: Route
|
||||
continued: ReturnType<typeof vi.fn>
|
||||
getFulfilled: () => FulfillOptions | undefined
|
||||
} {
|
||||
let fulfilled: FulfillOptions | undefined
|
||||
const continued = vi.fn(async () => {})
|
||||
|
||||
const route = {
|
||||
request: () =>
|
||||
({
|
||||
method: () => method,
|
||||
url: () => url,
|
||||
postDataJSON: () => requestBody
|
||||
}) as ReturnType<Route['request']>,
|
||||
continue: continued,
|
||||
fulfill: vi.fn(async (options?: FulfillOptions) => {
|
||||
if (!options) {
|
||||
throw new Error('Expected route to be fulfilled with options')
|
||||
}
|
||||
|
||||
fulfilled = options
|
||||
})
|
||||
} satisfies Pick<Route, 'request' | 'continue' | 'fulfill'>
|
||||
|
||||
return {
|
||||
route: route as unknown as Route,
|
||||
continued,
|
||||
getFulfilled: () => fulfilled
|
||||
}
|
||||
}
|
||||
|
||||
function bodyToText(body: FulfillOptions['body']): string {
|
||||
if (body instanceof Uint8Array) {
|
||||
return Buffer.from(body).toString('utf-8')
|
||||
}
|
||||
|
||||
return `${body ?? ''}`
|
||||
}
|
||||
|
||||
async function invokeJsonRoute<T>(
|
||||
handler: RouteHandler,
|
||||
args: {
|
||||
url: string
|
||||
requestBody?: unknown
|
||||
}
|
||||
): Promise<{
|
||||
status: number | undefined
|
||||
body: T
|
||||
}> {
|
||||
const invocation = createRouteInvocation(args)
|
||||
|
||||
await handler(invocation.route)
|
||||
|
||||
const fulfilled = invocation.getFulfilled()
|
||||
expect(fulfilled).toBeDefined()
|
||||
|
||||
return {
|
||||
status: fulfilled?.status,
|
||||
body: JSON.parse(bodyToText(fulfilled?.body)) as T
|
||||
}
|
||||
}
|
||||
|
||||
describe('InMemoryJobsBackend', () => {
|
||||
it('lists jobs sorted by create_time descending by default', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([
|
||||
createSeededJob({ id: 'job-oldest', createTime: 1_000 }),
|
||||
createSeededJob({ id: 'job-newest', createTime: 3_000 }),
|
||||
createSeededJob({ id: 'job-middle', createTime: 2_000 })
|
||||
])
|
||||
|
||||
const listRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs'
|
||||
)
|
||||
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
|
||||
url: 'http://localhost/api/jobs?offset=-1&limit=0'
|
||||
})
|
||||
|
||||
expect(response.body.jobs.map((job) => job.id)).toEqual([
|
||||
'job-newest',
|
||||
'job-middle',
|
||||
'job-oldest'
|
||||
])
|
||||
expect(response.body.pagination).toEqual({
|
||||
offset: 0,
|
||||
limit: 3,
|
||||
total: 3,
|
||||
has_more: false
|
||||
})
|
||||
})
|
||||
|
||||
it('filters by status and workflow_id, then sorts and paginates by execution_duration', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([
|
||||
createSeededJob({
|
||||
id: 'job-fast',
|
||||
status: 'completed',
|
||||
workflowId: 'wf-1',
|
||||
createTime: 1_000,
|
||||
executionEndTime: 1_100
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-slow',
|
||||
status: 'completed',
|
||||
workflowId: 'wf-1',
|
||||
createTime: 2_000,
|
||||
executionEndTime: 4_000
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-other-workflow',
|
||||
status: 'completed',
|
||||
workflowId: 'wf-2',
|
||||
createTime: 3_000,
|
||||
executionEndTime: 8_000
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-pending',
|
||||
status: 'pending',
|
||||
workflowId: 'wf-1',
|
||||
createTime: 4_000
|
||||
})
|
||||
])
|
||||
|
||||
const listRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs'
|
||||
)
|
||||
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
|
||||
url: 'http://localhost/api/jobs?status=completed&workflow_id=wf-1&sort_by=execution_duration&sort_order=asc&offset=1&limit=1'
|
||||
})
|
||||
|
||||
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-slow'])
|
||||
expect(response.body.pagination).toEqual({
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
total: 2,
|
||||
has_more: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns job detail responses by id', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
const seededJob = createSeededJob({
|
||||
id: 'job-detail',
|
||||
createTime: 5_000,
|
||||
workflowId: 'wf-detail'
|
||||
})
|
||||
|
||||
await backend.seed([seededJob])
|
||||
|
||||
const detailRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs/job-detail'
|
||||
)
|
||||
const response = await invokeJsonRoute<JobDetailResponse>(
|
||||
detailRouteHandler,
|
||||
{
|
||||
url: 'http://localhost/api/jobs/job-detail'
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toEqual(seededJob.detail)
|
||||
})
|
||||
|
||||
it('returns 404 for unknown job detail requests', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([])
|
||||
|
||||
const detailRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs/missing-job'
|
||||
)
|
||||
const response = await invokeJsonRoute<{ error: string }>(
|
||||
detailRouteHandler,
|
||||
{
|
||||
url: 'http://localhost/api/jobs/missing-job'
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(response.body).toEqual({ error: 'Job not found' })
|
||||
})
|
||||
|
||||
it('clears terminal jobs while preserving in-progress jobs for history clear', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([
|
||||
createSeededJob({
|
||||
id: 'job-completed',
|
||||
status: 'completed',
|
||||
createTime: 1_000
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-failed',
|
||||
status: 'failed',
|
||||
createTime: 2_000
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-running',
|
||||
status: 'in_progress',
|
||||
createTime: 3_000
|
||||
})
|
||||
])
|
||||
|
||||
const historyRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/history'
|
||||
)
|
||||
const clearInvocation = createRouteInvocation({
|
||||
url: 'http://localhost/api/history',
|
||||
requestBody: { clear: true }
|
||||
})
|
||||
|
||||
await historyRouteHandler(clearInvocation.route)
|
||||
|
||||
const listRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs'
|
||||
)
|
||||
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
|
||||
url: 'http://localhost/api/jobs'
|
||||
})
|
||||
|
||||
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-running'])
|
||||
})
|
||||
|
||||
it('deletes specific jobs via the history endpoint', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([
|
||||
createSeededJob({ id: 'job-keep', createTime: 1_000 }),
|
||||
createSeededJob({ id: 'job-delete', createTime: 2_000 })
|
||||
])
|
||||
|
||||
const historyRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/history'
|
||||
)
|
||||
const deleteInvocation = createRouteInvocation({
|
||||
url: 'http://localhost/api/history',
|
||||
requestBody: { delete: ['job-delete'] }
|
||||
})
|
||||
|
||||
await historyRouteHandler(deleteInvocation.route)
|
||||
|
||||
const listRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs'
|
||||
)
|
||||
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
|
||||
url: 'http://localhost/api/jobs'
|
||||
})
|
||||
|
||||
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-keep'])
|
||||
})
|
||||
|
||||
it('falls through non-POST history requests', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([createSeededJob({ id: 'job-history', createTime: 1 })])
|
||||
|
||||
const historyRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/history'
|
||||
)
|
||||
const invocation = createRouteInvocation({
|
||||
url: 'http://localhost/api/history',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
await historyRouteHandler(invocation.route)
|
||||
|
||||
expect(invocation.continued).toHaveBeenCalledTimes(1)
|
||||
expect(invocation.getFulfilled()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
213
browser_tests/fixtures/helpers/InMemoryJobsBackend.ts
Normal file
213
browser_tests/fixtures/helpers/InMemoryJobsBackend.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
|
||||
export type SeededJob = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: JobEntry): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
|
||||
return end - start
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class InMemoryJobsBackend {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private seededJobs = new Map<string, SeededJob>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async seed(jobs: SeededJob[]): Promise<void> {
|
||||
this.seededJobs = new Map(
|
||||
jobs.map((job) => [job.listItem.id, job] satisfies [string, SeededJob])
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.seededJobs.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.seededJobs.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.seededJobs.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.seededJobs = new Map(
|
||||
Array.from(this.seededJobs).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.seededJobs.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export class KeyboardHelper {
|
||||
locator: Locator | null = this.canvas
|
||||
): Promise<void> {
|
||||
const target = locator ?? this.page.keyboard
|
||||
await target.press(`Control+${keyToPress}`)
|
||||
await target.press(`ControlOrMeta+${keyToPress}`)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class NodeOperationsHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
public readonly promptDialogInput: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
this.promptDialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
}
|
||||
|
||||
private get page() {
|
||||
return this.comfyPage.page
|
||||
@@ -155,10 +161,6 @@ 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')
|
||||
|
||||
@@ -157,7 +157,7 @@ export class SubgraphHelper {
|
||||
|
||||
// Wait for the appropriate UI element to appear
|
||||
if (action === 'rightClick') {
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
await this.page.locator('.litemenu-entry').first().waitFor({
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
@@ -2,14 +2,12 @@ import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ToastHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
public readonly visibleToasts: Locator
|
||||
public readonly toastErrors: Locator
|
||||
|
||||
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')
|
||||
constructor(private readonly page: Page) {
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0): Promise<void> {
|
||||
@@ -28,6 +26,6 @@ export class ToastHelper {
|
||||
}
|
||||
|
||||
// Assert all toasts are closed
|
||||
await expect(this.visibleToasts).toHaveCount(0, { timeout: 1000 })
|
||||
await expect(this.visibleToasts).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
32
browser_tests/fixtures/helpers/assetScenarioTypes.ts
Normal file
32
browser_tests/fixtures/helpers/assetScenarioTypes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
|
||||
export type ImportedAssetFixture = {
|
||||
name: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
export type GeneratedOutputFixture = {
|
||||
filename: string
|
||||
displayName?: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
mediaType?: 'images' | 'video' | 'audio'
|
||||
subfolder?: string
|
||||
type?: ResultItemType
|
||||
}
|
||||
|
||||
export type GeneratedJobFixture = {
|
||||
jobId: string
|
||||
status?: JobEntry['status']
|
||||
outputs: [GeneratedOutputFixture, ...GeneratedOutputFixture[]]
|
||||
createdAt?: string
|
||||
createTime?: number
|
||||
executionStartTime?: number
|
||||
executionEndTime?: number
|
||||
workflowId?: string
|
||||
workflow?: JobDetailResponse['workflow']
|
||||
nodeId?: string
|
||||
}
|
||||
100
browser_tests/fixtures/helpers/buildMockJobOutputs.test.ts
Normal file
100
browser_tests/fixtures/helpers/buildMockJobOutputs.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
|
||||
describe('buildMockJobOutputs', () => {
|
||||
it('defaults nodeId, mediaType, subfolder, and type for a single output', () => {
|
||||
const job = {
|
||||
jobId: 'job-1',
|
||||
outputs: [{ filename: 'single-output.png' }]
|
||||
} satisfies GeneratedJobFixture
|
||||
const outputs = [
|
||||
{
|
||||
filename: 'single-output.png',
|
||||
displayName: 'Single output'
|
||||
}
|
||||
] satisfies GeneratedOutputFixture[]
|
||||
|
||||
expect(buildMockJobOutputs(job, outputs)).toEqual({
|
||||
'5': {
|
||||
images: [
|
||||
{
|
||||
filename: 'single-output.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Single output'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('buckets outputs by media type and preserves order within each bucket', () => {
|
||||
const job = {
|
||||
jobId: 'job-2',
|
||||
nodeId: '12',
|
||||
outputs: [{ filename: 'preview.png' }]
|
||||
} satisfies GeneratedJobFixture
|
||||
const outputs = [
|
||||
{
|
||||
filename: 'image-a.png',
|
||||
mediaType: 'images',
|
||||
subfolder: 'gallery',
|
||||
type: 'temp'
|
||||
},
|
||||
{
|
||||
filename: 'clip.mp4',
|
||||
mediaType: 'video',
|
||||
displayName: 'Clip'
|
||||
},
|
||||
{
|
||||
filename: 'image-b.png',
|
||||
mediaType: 'images',
|
||||
displayName: 'Second image'
|
||||
},
|
||||
{
|
||||
filename: 'sound.wav',
|
||||
mediaType: 'audio'
|
||||
}
|
||||
] satisfies GeneratedOutputFixture[]
|
||||
|
||||
expect(buildMockJobOutputs(job, outputs)).toEqual({
|
||||
'12': {
|
||||
images: [
|
||||
{
|
||||
filename: 'image-a.png',
|
||||
subfolder: 'gallery',
|
||||
type: 'temp',
|
||||
display_name: undefined
|
||||
},
|
||||
{
|
||||
filename: 'image-b.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Second image'
|
||||
}
|
||||
],
|
||||
video: [
|
||||
{
|
||||
filename: 'clip.mp4',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Clip'
|
||||
}
|
||||
],
|
||||
audio: [
|
||||
{
|
||||
filename: 'sound.wav',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
34
browser_tests/fixtures/helpers/buildMockJobOutputs.ts
Normal file
34
browser_tests/fixtures/helpers/buildMockJobOutputs.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { JobDetailResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
|
||||
export function buildMockJobOutputs(
|
||||
job: GeneratedJobFixture,
|
||||
outputs: GeneratedOutputFixture[]
|
||||
): NonNullable<JobDetailResponse['outputs']> {
|
||||
const nodeId = job.nodeId ?? '5'
|
||||
const nodeOutputs: Pick<TaskOutput[string], 'audio' | 'images' | 'video'> = {}
|
||||
|
||||
for (const output of outputs) {
|
||||
const mediaType = output.mediaType ?? 'images'
|
||||
|
||||
nodeOutputs[mediaType] = [
|
||||
...(nodeOutputs[mediaType] ?? []),
|
||||
{
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder ?? '',
|
||||
type: output.type ?? 'output',
|
||||
display_name: output.displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const taskOutput = { [nodeId]: nodeOutputs } satisfies TaskOutput
|
||||
|
||||
return taskOutput
|
||||
}
|
||||
47
browser_tests/fixtures/helpers/jobFixtures.ts
Normal file
47
browser_tests/fixtures/helpers/jobFixtures.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<JobEntry>
|
||||
): JobEntry[] {
|
||||
const now = Date.now()
|
||||
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
createMockJob({
|
||||
id: `job-${String(index + 1).padStart(3, '0')}`,
|
||||
create_time: now - index * 60_000,
|
||||
execution_start_time: now - index * 60_000,
|
||||
execution_end_time: now - index * 60_000 + (5 + index) * 1_000,
|
||||
preview_output: {
|
||||
filename: `image_${String(index + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
85
browser_tests/fixtures/helpers/seededAssetFiles.test.ts
Normal file
85
browser_tests/fixtures/helpers/seededAssetFiles.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSeededFileKey,
|
||||
buildSeededFiles
|
||||
} from '@e2e/fixtures/helpers/seededAssetFiles'
|
||||
|
||||
describe('buildSeededFiles', () => {
|
||||
it('keys seeded files by filename, type, and subfolder', () => {
|
||||
const seededFiles = buildSeededFiles({
|
||||
generated: [
|
||||
{
|
||||
jobId: 'job-root',
|
||||
outputs: [
|
||||
{
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
filePath: '/tmp/root-output.txt',
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
jobId: 'job-nested',
|
||||
outputs: [
|
||||
{
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: 'nested/folder',
|
||||
filePath: '/tmp/nested-output.txt',
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
imported: [
|
||||
{
|
||||
name: 'shared-name.txt',
|
||||
filePath: '/tmp/input-asset.txt',
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
filePath: '/tmp/root-output.txt',
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
|
||||
expect(
|
||||
seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: 'nested/folder'
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
filePath: '/tmp/nested-output.txt',
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
|
||||
expect(
|
||||
seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename: 'shared-name.txt',
|
||||
type: 'input',
|
||||
subfolder: ''
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
filePath: '/tmp/input-asset.txt',
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
})
|
||||
})
|
||||
142
browser_tests/fixtures/helpers/seededAssetFiles.ts
Normal file
142
browser_tests/fixtures/helpers/seededAssetFiles.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture,
|
||||
ImportedAssetFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export type SeededAssetFile = {
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
textContent?: string
|
||||
}
|
||||
|
||||
export type SeededFileLocation = {
|
||||
filename: string
|
||||
type: string
|
||||
subfolder: string
|
||||
}
|
||||
|
||||
function getFixturePath(relativePath: string): string {
|
||||
return path.resolve(helperDir, '../../assets', relativePath)
|
||||
}
|
||||
|
||||
export function buildSeededFileKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}: SeededFileLocation): string {
|
||||
return new URLSearchParams({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}).toString()
|
||||
}
|
||||
|
||||
export function defaultFileFor(filename: string): SeededAssetFile {
|
||||
const normalized = filename.toLowerCase()
|
||||
|
||||
if (normalized.endsWith('.png')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
|
||||
contentType: 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webp')) {
|
||||
return {
|
||||
filePath: getFixturePath('example.webp'),
|
||||
contentType: 'image/webp'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webm')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.webm'),
|
||||
contentType: 'video/webm'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.mp4')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
|
||||
contentType: 'video/mp4'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.glb')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.glb'),
|
||||
contentType: 'model/gltf-binary'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.json')) {
|
||||
return {
|
||||
textContent: JSON.stringify({ mocked: true }, null, 2),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: 'mocked asset content',
|
||||
contentType: getMimeType(filename)
|
||||
}
|
||||
}
|
||||
|
||||
function outputLocation(output: GeneratedOutputFixture): SeededFileLocation {
|
||||
return {
|
||||
filename: output.filename,
|
||||
type: output.type ?? 'output',
|
||||
subfolder: output.subfolder ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function importedAssetLocation(
|
||||
asset: ImportedAssetFixture
|
||||
): SeededFileLocation {
|
||||
return {
|
||||
filename: asset.name,
|
||||
type: 'input',
|
||||
subfolder: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSeededFiles({
|
||||
generated,
|
||||
imported
|
||||
}: {
|
||||
generated: readonly GeneratedJobFixture[]
|
||||
imported: readonly ImportedAssetFixture[]
|
||||
}): Map<string, SeededAssetFile> {
|
||||
const seededFiles = new Map<string, SeededAssetFile>()
|
||||
|
||||
for (const job of generated) {
|
||||
for (const output of job.outputs) {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
seededFiles.set(buildSeededFileKey(outputLocation(output)), {
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of imported) {
|
||||
const fallback = defaultFileFor(asset.name)
|
||||
|
||||
seededFiles.set(buildSeededFileKey(importedAssetLocation(asset)), {
|
||||
filePath: asset.filePath ?? fallback.filePath,
|
||||
contentType: asset.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
|
||||
return seededFiles
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export const TestIds = {
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
closeMinimapButton: 'close-minimap-button',
|
||||
minimapContainer: 'minimap-container',
|
||||
minimapCanvas: 'minimap-canvas',
|
||||
minimapViewport: 'minimap-viewport',
|
||||
minimapInteractionOverlay: 'minimap-interaction-overlay',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
@@ -79,7 +83,8 @@ export const TestIds = {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel'
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
@@ -130,6 +135,24 @@ export const TestIds = {
|
||||
outputPlaceholder: 'builder-output-placeholder',
|
||||
connectOutputPopover: 'builder-connect-output-popover'
|
||||
},
|
||||
outputHistory: {
|
||||
outputs: 'linear-outputs',
|
||||
welcome: 'linear-welcome',
|
||||
outputInfo: 'linear-output-info',
|
||||
activeQueue: 'linear-job',
|
||||
queueBadge: 'linear-job-badge',
|
||||
inProgressItem: 'linear-in-progress-item',
|
||||
historyItem: 'linear-history-item',
|
||||
skeleton: 'linear-skeleton',
|
||||
latentPreview: 'linear-latent-preview',
|
||||
imageOutput: 'linear-image-output',
|
||||
videoOutput: 'linear-video-output',
|
||||
cancelRun: 'linear-cancel-run',
|
||||
headerProgressBar: 'linear-header-progress-bar',
|
||||
itemProgressBar: 'linear-item-progress-bar',
|
||||
progressOverall: 'linear-progress-overall',
|
||||
progressNode: 'linear-progress-node'
|
||||
},
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item',
|
||||
welcome: 'linear-welcome',
|
||||
@@ -180,6 +203,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
|
||||
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
|
||||
@@ -14,11 +14,12 @@ function makeMatcher<T>(
|
||||
) {
|
||||
await expect(async () => {
|
||||
const value = await getValue(node)
|
||||
const assertion = this.isNot
|
||||
? expect(value, 'Node is ' + type).not
|
||||
: expect(value, 'Node is not ' + type)
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
if (this.isNot) {
|
||||
expect(value, 'Node is ' + type).not.toBeTruthy()
|
||||
} else {
|
||||
expect(value, 'Node is not ' + type).toBeTruthy()
|
||||
}
|
||||
}).toPass({ timeout: 5000, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
@@ -30,7 +31,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 = { timeout: 256 }) {
|
||||
async toHaveFocus(locator: Locator, options = {}) {
|
||||
await expect
|
||||
.poll(
|
||||
() => locator.evaluate((el) => el === document.activeElement),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
@@ -356,7 +355,11 @@ export class NodeReference {
|
||||
}
|
||||
async click(
|
||||
position: 'title' | 'collapse',
|
||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
moveMouseToEmptyArea?: boolean
|
||||
}
|
||||
) {
|
||||
let clickPos: Position
|
||||
switch (position) {
|
||||
@@ -377,12 +380,7 @@ export class NodeReference {
|
||||
delete options.moveMouseToEmptyArea
|
||||
}
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
|
||||
if (moveMouseToEmptyArea) {
|
||||
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
}
|
||||
@@ -499,31 +497,18 @@ export class NodeReference {
|
||||
|
||||
await expect(async () => {
|
||||
// Try just clicking the enter button first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
position: subgraphButtonPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
// Double-click to enter subgraph
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseDblclickAt(position)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
}
|
||||
|
||||
@@ -4,38 +4,24 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {}
|
||||
public readonly header: Locator
|
||||
public readonly title: Locator
|
||||
public readonly titleInput: Locator
|
||||
public readonly body: Locator
|
||||
public readonly pinIndicator: Locator
|
||||
public readonly collapseButton: Locator
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
|
||||
get header(): Locator {
|
||||
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
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
this.title = locator.getByTestId('node-title')
|
||||
this.titleInput = locator.getByTestId('node-title-input')
|
||||
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
@@ -1,53 +1,31 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
||||
getWebSocket: () => Promise<WebSocketRoute>
|
||||
}>({
|
||||
ws: [
|
||||
async ({ page }, use) => {
|
||||
// Each time a page loads, to catch navigations
|
||||
page.on('load', async () => {
|
||||
await page.evaluate(function () {
|
||||
// Create a wrapper for WebSocket that stores them globally
|
||||
// so we can look it up to trigger messages
|
||||
const store: Record<string, WebSocket> = (window.__ws__ = {})
|
||||
window.WebSocket = class extends window.WebSocket {
|
||||
constructor(
|
||||
...rest: ConstructorParameters<typeof window.WebSocket>
|
||||
) {
|
||||
super(...rest)
|
||||
store[this.url] = this
|
||||
}
|
||||
}
|
||||
getWebSocket: [
|
||||
async ({ context }, use) => {
|
||||
let latest: WebSocketRoute | undefined
|
||||
let resolve: ((ws: WebSocketRoute) => void) | undefined
|
||||
|
||||
await context.routeWebSocket(/\/ws/, (ws) => {
|
||||
const server = ws.connectToServer()
|
||||
server.onMessage((message) => {
|
||||
ws.send(message)
|
||||
})
|
||||
|
||||
latest = ws
|
||||
resolve?.(ws)
|
||||
})
|
||||
|
||||
await use({
|
||||
async trigger(data, url) {
|
||||
// Trigger a websocket event on the page
|
||||
await page.evaluate(
|
||||
function ([data, url]) {
|
||||
if (!url) {
|
||||
// If no URL specified, use page URL
|
||||
const u = new URL(window.location.href)
|
||||
u.hash = ''
|
||||
u.protocol = 'ws:'
|
||||
u.pathname = '/'
|
||||
url = u.toString() + 'ws'
|
||||
}
|
||||
const ws: WebSocket = window.__ws__![url]
|
||||
ws.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data
|
||||
})
|
||||
)
|
||||
},
|
||||
[JSON.stringify(data), url]
|
||||
)
|
||||
}
|
||||
await use(() => {
|
||||
if (latest) return Promise.resolve(latest)
|
||||
return new Promise<WebSocketRoute>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
})
|
||||
},
|
||||
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -82,11 +82,9 @@ export async function builderSaveAs(
|
||||
viewType: 'App' | 'Node graph' = 'App'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await comfyExpect(appMode.saveAs.nameInput).toBeVisible()
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await comfyExpect(appMode.saveAs.successMessage).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,3 +122,21 @@ 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()
|
||||
}
|
||||
|
||||
66
browser_tests/helpers/painter.ts
Normal file
66
browser_tests/helpers/painter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
|
||||
export async function drawStroke(
|
||||
page: Page,
|
||||
canvas: Locator,
|
||||
opts: { startXPct?: number; endXPct?: number; yPct?: number } = {}
|
||||
): Promise<void> {
|
||||
const { startXPct = 0.3, endXPct = 0.7, yPct = 0.5 } = opts
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
await page.mouse.move(
|
||||
box.x + box.width * startXPct,
|
||||
box.y + box.height * yPct
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(
|
||||
box.x + box.width * endXPct,
|
||||
box.y + box.height * yPct,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
return canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const { data } = ctx.getImageData(0, 0, el.width, el.height)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export async function triggerSerialization(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
if (!graph) {
|
||||
throw new Error(
|
||||
'Global window.graph is absent. Ensure workflow fixture is loaded.'
|
||||
)
|
||||
}
|
||||
|
||||
const node = graph._nodes_by_id?.['1']
|
||||
if (!node) {
|
||||
throw new Error(
|
||||
'Target node with ID "1" not found in graph._nodes_by_id.'
|
||||
)
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === 'mask')
|
||||
if (!widget) {
|
||||
throw new Error('Widget "mask" not found on target node 1.')
|
||||
}
|
||||
|
||||
if (typeof widget.serializeValue !== 'function') {
|
||||
throw new Error(
|
||||
'mask widget on node 1 does not have a serializeValue function.'
|
||||
)
|
||||
}
|
||||
|
||||
await widget.serializeValue(node, 0)
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '@/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
@@ -9,17 +8,15 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
/**
|
||||
* This test ensures that the autoqueue change mode can only queue one change at a time
|
||||
*/
|
||||
test('Does not auto-queue multiple changes at a time', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
|
||||
// Enable change auto-queue mode
|
||||
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
await expect.poll(() => queueOpts.getMode()).toBe('disabled')
|
||||
@@ -62,17 +59,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
}
|
||||
|
||||
// Trigger a status websocket message
|
||||
const triggerStatus = async (queueSize: number) => {
|
||||
await ws.trigger({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: {
|
||||
exec_info: {
|
||||
queue_remaining: queueSize
|
||||
const triggerStatus = (queueSize: number) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: {
|
||||
exec_info: {
|
||||
queue_remaining: queueSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as StatusWsMessage)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Extract the width from the queue response
|
||||
@@ -104,8 +103,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
).toBe(1)
|
||||
|
||||
// Trigger a status update so auto-queue re-runs
|
||||
await triggerStatus(1)
|
||||
await triggerStatus(0)
|
||||
triggerStatus(1)
|
||||
triggerStatus(0)
|
||||
|
||||
// Ensure the queued width is the last queued value
|
||||
expect(
|
||||
|
||||
@@ -75,9 +75,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
// Scroll to bottom so the codec widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
@@ -90,7 +88,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({ timeout: 5000 })
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -123,9 +121,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
// Scroll to bottom so the image widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
@@ -144,7 +140,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({ timeout: 5000 })
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
|
||||
const RESIZE_NODE_ID = '1'
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('Build app button is visible when no outputs selected', async ({
|
||||
@@ -26,7 +26,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
})
|
||||
|
||||
test('Empty workflow and build app are hidden when app has outputs', async ({
|
||||
@@ -35,8 +35,8 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
|
||||
@@ -46,7 +46,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
await comfyPage.appMode.backToWorkflowButton.click()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||
})
|
||||
|
||||
test('Load template opens template selector', async ({ comfyPage }) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
const menu = appMode.select.getInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await expect(menu).toBeVisible()
|
||||
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({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
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({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await expect(menu).toBeVisible()
|
||||
await appMode.select.renameWidget(menu, 'Preview Seed')
|
||||
|
||||
// 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({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await appMode.footer.exitBuilder()
|
||||
await appMode.toggleAppMode()
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
|
||||
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({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ test.describe('App mode widget values in prompt', { tag: '@ui' }, () => {
|
||||
({ nodeId, widgetName }) => [nodeId, widgetName]
|
||||
)
|
||||
await appMode.enterAppModeWithInputs(inputs)
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
|
||||
for (const { nodeId, widgetName, type, fill } of WIDGET_TEST_DATA) {
|
||||
const key = `${nodeId}:${widgetName}`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createAssetHelper,
|
||||
withModels,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
STABLE_OUTPUT
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
test.describe('AssetHelper', () => {
|
||||
test.describe('operators and configuration', () => {
|
||||
test('creates helper with models via withModels operator', async ({
|
||||
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
|
||||
})
|
||||
|
||||
test.describe('mock API routes', () => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
@@ -87,12 +89,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(2)
|
||||
expect(data.has_more).toBe(false)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets respects pagination params', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets respects pagination params', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withModels(5),
|
||||
withPagination({ total: 10, hasMore: true })
|
||||
@@ -110,12 +112,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(10)
|
||||
expect(data.has_more).toBe(true)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets filters by include_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
@@ -129,14 +131,12 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets).toHaveLength(1)
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
|
||||
`${comfyPage.url}/api/assets/nonexistent-id`
|
||||
)
|
||||
expect(notFound.status).toBe(404)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('PUT /assets/:id updates asset in store', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -175,14 +175,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
|
||||
'renamed.safetensors'
|
||||
)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('DELETE /assets/:id removes asset from store', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -193,11 +191,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(204)
|
||||
expect(assetApi.assetCount).toBe(1)
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets returns upload response', async ({ comfyPage }) => {
|
||||
test('POST /assets returns upload response', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const customUpload = {
|
||||
id: 'custom-upload-001',
|
||||
name: 'custom.safetensors',
|
||||
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
created_new: true
|
||||
}
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withUploadResponse(customUpload))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -217,14 +215,12 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { id: string; name: string }
|
||||
expect(data.id).toBe('custom-upload-001')
|
||||
expect(data.name).toBe('custom.safetensors')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets/download returns async download response', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -235,14 +231,14 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { task_id: string; status: string }
|
||||
expect(data.task_id).toBe('download-task-001')
|
||||
expect(data.status).toBe('created')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mutation tracking', () => {
|
||||
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('tracks POST, PUT, DELETE mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -265,12 +261,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(mutations[0].method).toBe('POST')
|
||||
expect(mutations[1].method).toBe('PUT')
|
||||
expect(mutations[2].method).toBe('DELETE')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET requests are not tracked as mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -280,14 +276,14 @@ test.describe('AssetHelper', () => {
|
||||
)
|
||||
|
||||
expect(assetApi.getMutations()).toHaveLength(0)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mockError', () => {
|
||||
test('returns error status for all asset routes', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('returns error status for all asset routes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mockError(503, 'Service Unavailable')
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -296,16 +292,14 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(503)
|
||||
const data = body as { error: string }
|
||||
expect(data.error).toBe('Service Unavailable')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('clearMocks', () => {
|
||||
test('resets store, mutations, and unroutes handlers', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
|
||||
@@ -4,14 +4,10 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
})
|
||||
@@ -35,7 +31,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('should switch between shortcuts and terminal panels', async ({
|
||||
@@ -55,7 +51,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
await expect(logsTab).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).not.toBeVisible()
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('should persist Logs tab content in bottom panel', async ({
|
||||
|
||||
@@ -3,18 +3,14 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
|
||||
@@ -182,7 +178,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('should display shortcuts in organized columns', async ({
|
||||
@@ -192,9 +188,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="shortcuts-columns"]')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('shortcuts-columns')).toBeVisible()
|
||||
|
||||
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
|
||||
await expect.poll(() => subcategoryTitles.count()).toBeGreaterThanOrEqual(2)
|
||||
@@ -205,7 +199,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import {
|
||||
builderSaveAs,
|
||||
createAndSaveApp,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
@@ -21,7 +22,7 @@ async function saveCloseAndReopenAsApp(
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).not.toBeVisible()
|
||||
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
@@ -122,7 +123,7 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
const workflowName = `${Date.now()} reorder-preview`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
@@ -147,11 +148,58 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
const workflowName = `${Date.now()} reorder-persist`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets).toBeVisible()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ async function reSaveAs(
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.openSaveAsFromChevron()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible()
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ async function dismissSuccessDialog(
|
||||
) {
|
||||
const btn = button === 'close' ? saveAs.closeButton : saveAs.dismissButton
|
||||
await btn.click()
|
||||
await expect(saveAs.successDialog).not.toBeVisible()
|
||||
await expect(saveAs.successDialog).toBeHidden()
|
||||
}
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
@@ -48,7 +48,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.dialog).toBeVisible()
|
||||
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({ timeout: 5000 })
|
||||
await expect(saveAs.dialog).toBeVisible()
|
||||
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({ timeout: 5000 })
|
||||
await expect(saveAs.dialog).toBeVisible()
|
||||
|
||||
const appRadio = saveAs.viewTypeRadio('App')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
@@ -113,7 +113,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
@@ -121,7 +121,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||
})
|
||||
|
||||
test('Save button directly saves for previously saved workflow', async ({
|
||||
@@ -136,12 +136,12 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
// Modify the workflow so the save button becomes enabled
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await comfyPage.appMode.select.deleteInput('seed')
|
||||
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
|
||||
await expect(footer.saveButton).toBeEnabled()
|
||||
|
||||
await footer.saveButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(saveAs.dialog).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(saveAs.dialog).toBeHidden()
|
||||
await expect(footer.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -156,7 +156,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
await footer.openSaveAsFromChevron()
|
||||
|
||||
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.title).toBeVisible()
|
||||
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({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('save as app produces correct extension and linearMode', async ({
|
||||
@@ -253,7 +253,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
|
||||
|
||||
await comfyPage.appMode.saveAs.viewAppButton.click()
|
||||
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowActiveAppMode())
|
||||
@@ -271,9 +271,9 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
)
|
||||
|
||||
await comfyPage.appMode.saveAs.exitBuilderButton.click()
|
||||
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||
})
|
||||
|
||||
test('save as with different mode does not modify the original workflow', async ({
|
||||
@@ -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({ timeout: 5000 })
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible()
|
||||
|
||||
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({ timeout: 5000 })
|
||||
await expect(appMode.saveAs.overwriteDialog).toBeVisible()
|
||||
await appMode.saveAs.overwriteButton.click()
|
||||
await expect(appMode.saveAs.overwriteDialog).not.toBeVisible()
|
||||
await expect(appMode.saveAs.overwriteDialog).toBeHidden()
|
||||
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible()
|
||||
|
||||
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({ timeout: 5000 })
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
|
||||
@@ -70,7 +70,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await handItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(menu).toBeHidden()
|
||||
})
|
||||
|
||||
test('closes when Escape is pressed', async ({ comfyPage }) => {
|
||||
@@ -91,7 +91,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
@@ -197,7 +197,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
await selectItem.press('ArrowDown')
|
||||
await handItem.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -206,8 +206,8 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
|
||||
// Ensure undo reverts both changes
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeBypassed({ timeout: 5000 })
|
||||
await expect(node).not.toBeCollapsed({ timeout: 5000 })
|
||||
await expect(node).not.toBeBypassed()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
await waitForChangeTrackerSettled(comfyPage, {
|
||||
isModified: false,
|
||||
redoQueueSize: 1,
|
||||
|
||||
@@ -75,15 +75,18 @@ 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)
|
||||
})
|
||||
.poll(
|
||||
async () => {
|
||||
return await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
return String(widget?.value ?? '')
|
||||
}, nodeId)
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
|
||||
@@ -8,10 +8,9 @@ const NODE_TITLE = 'KSampler'
|
||||
|
||||
test.describe(
|
||||
'Collapsed node link positions',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
{ tag: ['@canvas', '@node', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
@@ -149,11 +149,7 @@ 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(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(3)
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
|
||||
// Step 2: Paste image onto selected LoadImage node
|
||||
const loadImageNodes =
|
||||
@@ -171,13 +167,10 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
await uploadPromise
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const fileWidget = await loadImageNodes[0].getWidget(0)
|
||||
return fileWidget.getValue()
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.poll(async () => {
|
||||
const fileWidget = await loadImageNodes[0].getWidget(0)
|
||||
return fileWidget.getValue()
|
||||
})
|
||||
.toContain('image32x32')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
|
||||
@@ -194,11 +187,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
)
|
||||
await uploadPromise2
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(4)
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(4)
|
||||
const allLoadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
expect(allLoadImageNodes).toHaveLength(2)
|
||||
|
||||
@@ -20,10 +20,6 @@ async function verifyCustomIconSvg(iconElement: Locator) {
|
||||
}
|
||||
|
||||
test.describe('Custom Icons', { tag: '@settings' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('sidebar tab icons use custom SVGs', async ({ comfyPage }) => {
|
||||
// Find the icon in the sidebar
|
||||
const icon = comfyPage.page.locator(
|
||||
|
||||
@@ -20,7 +20,6 @@ async function pressKeyAndExpectRequest(
|
||||
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test.describe('Sidebar Toggle Shortcuts', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
@@ -38,13 +37,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
await expect(selectedButton).toBeHidden()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await expect(selectedButton).toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
await expect(selectedButton).toBeHidden()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -164,15 +163,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
|
||||
test.describe('Mode and Panel Toggles', () => {
|
||||
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Set up linearData so app mode has something to show
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
// Toggle off with Alt+m
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
|
||||
// Toggle on again
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
@@ -180,7 +177,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
})
|
||||
|
||||
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
@@ -189,22 +185,20 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await expect(minimap).not.toBeVisible()
|
||||
await expect(minimap).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await expect(minimap).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +244,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({ timeout: 3000 })
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
@@ -303,9 +297,7 @@ 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(), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
test.describe('Settings', () => {
|
||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const contentArea = settingsDialog.locator('main')
|
||||
await expect(contentArea).toBeVisible()
|
||||
@@ -26,17 +24,16 @@ test.describe('Settings', () => {
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.press(',')
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
const settingsLocator = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
const settingsLocator = comfyPage.page.getByTestId('settings-dialog')
|
||||
await expect(settingsLocator).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(settingsLocator).not.toBeVisible()
|
||||
await expect(settingsLocator).toBeHidden()
|
||||
})
|
||||
|
||||
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
|
||||
const maxSpeed = 2.5
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
|
||||
|
||||
await test.step('Setting should persist', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed'))
|
||||
@@ -49,9 +46,7 @@ test.describe('Settings', () => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
|
||||
// Open the keybinding tab
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
await settingsDialog
|
||||
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||
@@ -108,7 +103,6 @@ test.describe('Support', () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Prevent loading the external page
|
||||
await comfyPage.page
|
||||
.context()
|
||||
|
||||
@@ -307,7 +307,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||
await searchInput.fill('Test Pack B')
|
||||
|
||||
await expect(dialog.getByText('Test Pack B')).toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack A')).not.toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack A')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
|
||||
@@ -360,7 +360,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('Empty search shows no results message', async ({ comfyPage }) => {
|
||||
|
||||
@@ -60,7 +60,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
@@ -83,7 +83,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
await dialog.getByLabel('Close').click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
@@ -106,7 +106,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
const request = await clearPromise
|
||||
expect(request.postDataJSON()).toEqual({ clear: true })
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
|
||||
@@ -114,7 +114,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
await expect(dialog.root).toBeVisible()
|
||||
|
||||
await dialog.close()
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dialog', async ({ comfyPage }) => {
|
||||
@@ -70,7 +70,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
await expect(dialog.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Search filters settings list', async ({ comfyPage }) => {
|
||||
@@ -153,7 +153,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
const expanded = await select.getAttribute('aria-expanded')
|
||||
if (expanded !== 'true') await select.click()
|
||||
await expect(select).toHaveAttribute('aria-expanded', 'true')
|
||||
}).toPass({ timeout: 3000 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Pick the option that is not the current value
|
||||
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
|
||||
|
||||
@@ -7,7 +7,6 @@ test.describe('Sign In dialog', { tag: '@ui' }, () => {
|
||||
let dialog: SignInDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
dialog = new SignInDialog(comfyPage.page)
|
||||
await dialog.open()
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
|
||||
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(textareaWidget).not.toBeVisible()
|
||||
await expect(textareaWidget).toBeHidden()
|
||||
})
|
||||
|
||||
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {
|
||||
@@ -25,8 +25,8 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
for (const node of nodes) {
|
||||
await node.click('collapse')
|
||||
}
|
||||
await expect(firstMultiline).not.toBeVisible()
|
||||
await expect(lastMultiline).not.toBeVisible()
|
||||
await expect(firstMultiline).toBeHidden()
|
||||
await expect(lastMultiline).toBeHidden()
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -35,7 +35,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ test.describe('Error dialog', () => {
|
||||
}) => {
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
await expect(errorDialog.locator('pre')).not.toBeVisible()
|
||||
await expect(errorDialog.locator('pre')).toBeHidden()
|
||||
|
||||
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe('Error dialog', () => {
|
||||
await expect(reportPre).toHaveText(/\S/)
|
||||
await expect(
|
||||
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
|
||||
).not.toBeVisible()
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
@@ -47,16 +47,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test('Should display "Show missing models" button for missing model errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(
|
||||
`${url}/api/devtools/cleanup_fake_model`
|
||||
)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
)
|
||||
.toBeTruthy()
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
@@ -100,7 +91,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -112,10 +103,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
})
|
||||
|
||||
test('Does not resurface error overlay when switching back to workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = getOverlay(comfyPage.page)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,7 +174,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -168,7 +186,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
|
||||
test('"Dismiss" closes overlay without opening panel', async ({
|
||||
@@ -181,10 +199,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId('properties-panel')
|
||||
).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
@@ -195,7 +211,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user