mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-11 06:50:05 +00:00
Compare commits
9 Commits
infinite-s
...
test/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c427749c9a | ||
|
|
0353524e6f | ||
|
|
f1bb756929 | ||
|
|
d70c3cbfc2 | ||
|
|
e38dd1efae | ||
|
|
62779d3c51 | ||
|
|
4cf160d66e | ||
|
|
c0871ba219 | ||
|
|
6d4fc1bb10 |
@@ -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 }}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -73,15 +73,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() {
|
||||
@@ -183,6 +181,7 @@ export class ComfyPage {
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
public readonly visibleToasts: Locator
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -225,6 +224,7 @@ export class ComfyPage {
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.visibleToasts = this.toast.visibleToasts
|
||||
this.dragDrop = new DragDropHelper(page)
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
@@ -237,10 +237,6 @@ export class ComfyPage {
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
return this.toast.visibleToasts
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,22 +43,10 @@ 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'
|
||||
|
||||
@@ -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,137 +249,95 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
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' })
|
||||
}
|
||||
public readonly generatedTab: Locator
|
||||
public readonly importedTab: Locator
|
||||
|
||||
// --- Empty state ---
|
||||
public readonly emptyStateMessage: Locator
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
// --- Search & filter ---
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
public readonly gridViewOption: Locator
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
public readonly selectedCards: Locator
|
||||
|
||||
// --- List view items ---
|
||||
public readonly listViewItems: Locator
|
||||
|
||||
// --- Selection footer ---
|
||||
public readonly selectionFooter: Locator
|
||||
public readonly selectionCountButton: Locator
|
||||
public readonly deselectAllButton: Locator
|
||||
public readonly deleteSelectedButton: Locator
|
||||
public readonly downloadSelectedButton: Locator
|
||||
|
||||
// --- Folder view ---
|
||||
public readonly backToAssetsButton: Locator
|
||||
|
||||
// --- Loading ---
|
||||
public readonly skeletonLoaders: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
|
||||
this.importedTab = page.getByRole('tab', { name: 'Imported' })
|
||||
this.emptyStateMessage = page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.assetCards = page.locator('[role="button"][data-selected]')
|
||||
this.selectedCards = page.locator('[data-selected="true"]')
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
@@ -427,24 +353,20 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
.toHaveCount(0, { 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 +389,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}")`)
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,26 @@ export class AppModeHelper {
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
public readonly welcome: Locator
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
public readonly emptyWorkflowText: Locator
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
public readonly buildAppButton: Locator
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
public readonly backToWorkflowButton: Locator
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
public readonly loadTemplateButton: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
@@ -22,6 +42,31 @@ export class AppModeHelper {
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
)
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.locator('[data-testid="linear-widgets"]')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
TestIds.appMode.emptyWorkflow
|
||||
)
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.backToWorkflowButton = this.page.getByTestId(
|
||||
TestIds.appMode.backToWorkflow
|
||||
)
|
||||
this.loadTemplateButton = this.page.getByTestId(
|
||||
TestIds.appMode.loadTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -93,61 +138,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").
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -150,38 +160,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function makeMatcher<T>(
|
||||
? expect(value, 'Node is ' + type).not
|
||||
: expect(value, 'Node is not ' + type)
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
}).toPass({ timeout: 5000, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
@@ -30,7 +30,7 @@ export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
async toHaveFocus(locator: Locator, options = {}) {
|
||||
await expect
|
||||
.poll(
|
||||
() => locator.evaluate((el) => el === document.activeElement),
|
||||
|
||||
@@ -4,38 +4,26 @@ 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.locator('[data-testid="node-title"]')
|
||||
this.titleInput = locator.locator('[data-testid="node-title-input"]')
|
||||
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||
this.collapseButton = locator.locator(
|
||||
'[data-testid="node-collapse-button"]'
|
||||
)
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -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).not.toBeVisible()
|
||||
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 ({
|
||||
@@ -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.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())
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -250,7 +250,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
@@ -303,9 +303,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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -112,10 +112,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -63,19 +63,13 @@ test.describe(
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
|
||||
await expect
|
||||
.poll(async () => (await input.getWidget(0)).getValue(), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.poll(async () => (await input.getWidget(0)).getValue())
|
||||
.toBe('foo')
|
||||
await expect
|
||||
.poll(async () => (await output1.getWidget(0)).getValue(), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.poll(async () => (await output1.getWidget(0)).getValue())
|
||||
.toBe('foo')
|
||||
await expect
|
||||
.poll(async () => (await output2.getWidget(0)).getValue(), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.poll(async () => (await output2.getWidget(0)).getValue())
|
||||
.toBe('')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -351,16 +351,20 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-off.png'
|
||||
)
|
||||
// Re-expand: clicking the canvas toggler on a collapsed node is
|
||||
// unreliable because DOM widget overlays may intercept the pointer
|
||||
// event. Use programmatic collapse() for the expand step.
|
||||
// TODO(#11006): Restore click-to-expand once DOM widget overlay pointer interception is fixed
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)!
|
||||
node.collapse()
|
||||
window.app!.canvas.setDirty(true, true)
|
||||
}, targetNode.id)
|
||||
await comfyPage.nextFrame()
|
||||
// Wait for the double-click window (300ms) to expire so the next
|
||||
// click at the same position isn't interpreted as a double-click.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const pointer = window.app!.canvas.pointer
|
||||
if (!pointer.eLastDown) return true
|
||||
return performance.now() - pointer.eLastDown.timeStamp > 300
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
await comfyPage.canvas.click({
|
||||
position: togglerPos
|
||||
})
|
||||
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
|
||||
// Move mouse away to avoid hover highlight differences.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
|
||||
@@ -79,9 +79,7 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss, then close settings via Escape
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
@@ -133,9 +131,7 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
|
||||
// Export via ellipsis menu
|
||||
await menuButton.click()
|
||||
@@ -183,9 +179,7 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
|
||||
// Delete via ellipsis menu
|
||||
await menuButton.click()
|
||||
@@ -223,9 +217,7 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
|
||||
// Save as new preset via ellipsis menu
|
||||
await menuButton.click()
|
||||
@@ -237,9 +229,7 @@ test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
await promptInput.press('Enter')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
|
||||
// Verify preset trigger shows my-custom-preset
|
||||
await expect(presetTrigger).toContainText('my-custom-preset')
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
@@ -24,7 +24,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
@@ -32,7 +32,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
@@ -40,11 +40,11 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).not.toBeVisible()
|
||||
@@ -55,7 +55,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.canvas).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,6 @@ export class Load3DViewerHelper {
|
||||
}
|
||||
|
||||
async waitForClosed(): Promise<void> {
|
||||
await expect(this.dialog).toBeHidden({ timeout: 5000 })
|
||||
await expect(this.dialog).toBeHidden()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,16 +66,14 @@ test.describe('Load3D', () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
const config = n?.properties?.['Scene Config'] as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
return config?.backgroundColor
|
||||
}),
|
||||
{ timeout: 3000 }
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
const config = n?.properties?.['Scene Config'] as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
return config?.backgroundColor
|
||||
})
|
||||
)
|
||||
.toBe('#cc3333')
|
||||
|
||||
@@ -111,9 +109,7 @@ test.describe('Load3D', () => {
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
const modelFileWidget = await node.getWidget(0)
|
||||
await expect
|
||||
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
|
||||
.toContain('cube.obj')
|
||||
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -143,9 +139,7 @@ test.describe('Load3D', () => {
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
const modelFileWidget = await node.getWidget(0)
|
||||
await expect
|
||||
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
|
||||
.toContain('cube.obj')
|
||||
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -18,9 +18,7 @@ test.describe('Load3D Viewer', () => {
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
const modelFileWidget = await nodeRef.getWidget(0)
|
||||
await expect
|
||||
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
|
||||
.toContain('cube.obj')
|
||||
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
})
|
||||
|
||||
@@ -37,19 +37,17 @@ test.describe(
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible({ timeout: 3000 })
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -68,8 +66,7 @@ test.describe(
|
||||
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
|
||||
{
|
||||
message:
|
||||
'Menu should overflow vertically so this test exercises the viewport clamp',
|
||||
timeout: 3000
|
||||
'Menu should overflow vertically so this test exercises the viewport clamp'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -97,9 +94,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The node should be removed from the graph
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
|
||||
.toBe(0)
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -25,10 +25,7 @@ test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')).length,
|
||||
{
|
||||
timeout: 5000
|
||||
}
|
||||
(await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')).length
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
|
||||
@@ -45,14 +45,12 @@ test.describe(
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
|
||||
await comfyPage.page.click('[data-testid="more-options-button"]')
|
||||
|
||||
@@ -99,9 +97,7 @@ test.describe(
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
).toBeVisible()
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -118,7 +114,7 @@ test.describe(
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Color', { exact: true }).click()
|
||||
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
|
||||
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
|
||||
await expect(blueSwatch.first()).toBeVisible()
|
||||
await blueSwatch.first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -152,7 +148,7 @@ test.describe(
|
||||
}) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
|
||||
await expect(renameItem).toBeVisible({ timeout: 5000 })
|
||||
await expect(renameItem).toBeVisible()
|
||||
|
||||
// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
|
||||
for (let i = 0; i < 30; i++) {
|
||||
@@ -175,7 +171,7 @@ test.describe(
|
||||
await openMoreOptions(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const btn = document.querySelector(
|
||||
|
||||
@@ -187,7 +187,7 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
@@ -242,7 +242,7 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
@@ -253,14 +253,14 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -299,9 +299,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect
|
||||
.poll(() => tab.assetCards.count(), { timeout: 5000 })
|
||||
.toBeLessThan(initialCount)
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
@@ -316,7 +314,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
await expect(tab.assetCards).toHaveCount(initialCount)
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
@@ -325,7 +323,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,7 +382,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
@@ -398,7 +396,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
@@ -449,7 +447,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Download action for output asset', async ({
|
||||
@@ -522,7 +520,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await expect(
|
||||
tab.contextMenuItem('Open as workflow in new tab')
|
||||
@@ -552,8 +550,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
@@ -595,7 +593,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Footer shows delete button when output assets selected', async ({
|
||||
@@ -608,7 +606,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
@@ -629,7 +627,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
})
|
||||
})
|
||||
@@ -748,12 +746,10 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
await comfyPage.confirmDialog.delete.click()
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount - 1, {
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.assetCards).toHaveCount(initialCount - 1)
|
||||
|
||||
const successToast = comfyPage.page.locator('.p-toast-message-success')
|
||||
await expect(successToast).toBeVisible({ timeout: 5000 })
|
||||
await expect(successToast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancelling delete preserves asset', async ({ comfyPage }) => {
|
||||
|
||||
@@ -76,9 +76,7 @@ test.describe('Model library sidebar - folders', () => {
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
|
||||
// Models should appear as leaf nodes
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
|
||||
})
|
||||
@@ -91,9 +89,7 @@ test.describe('Model library sidebar - folders', () => {
|
||||
|
||||
await tab.getFolderByLabel('loras').click()
|
||||
|
||||
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -119,9 +115,7 @@ test.describe('Model library sidebar - search', () => {
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
|
||||
// Wait for debounce (300ms) + load + render
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
|
||||
// Other models should not be visible
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).not.toBeVisible()
|
||||
@@ -132,17 +126,13 @@ test.describe('Model library sidebar - search', () => {
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
|
||||
// Clear the search
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
// Folders should be visible again (collapsed)
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -152,7 +142,7 @@ test.describe('Model library sidebar - search', () => {
|
||||
|
||||
// Expand a folder and verify models are present before searching
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.leafNodes).not.toHaveCount(0, { timeout: 5000 })
|
||||
await expect(tab.leafNodes).not.toHaveCount(0)
|
||||
|
||||
await tab.searchInput.fill('nonexistent_model_xyz')
|
||||
|
||||
@@ -196,7 +186,7 @@ test.describe('Model library sidebar - refresh', () => {
|
||||
await tab.refreshButton.click()
|
||||
await refreshRequest
|
||||
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible({ timeout: 5000 })
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Load all folders button triggers loading all model data', async ({
|
||||
|
||||
@@ -45,9 +45,7 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(tab.getNode('KSampler (Advanced)')).not.toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('KSampler')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
|
||||
await expect(tab.getNode('CLIPLoader')).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -96,7 +94,7 @@ test.describe('Node library sidebar V2', () => {
|
||||
const contextMenu = comfyPage.page.getByRole('menuitem', {
|
||||
name: /Bookmark Node/
|
||||
})
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search clear restores folder view', async ({ comfyPage }) => {
|
||||
@@ -105,14 +103,12 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(tab.getFolder('sampling')).toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('KSampler')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
|
||||
|
||||
await tab.searchInput.clear()
|
||||
await tab.searchInput.press('Enter')
|
||||
|
||||
await expect(tab.getFolder('sampling')).toBeVisible({ timeout: 5000 })
|
||||
await expect(tab.getFolder('sampling')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Sort dropdown shows sorting options', async ({ comfyPage }) => {
|
||||
@@ -122,7 +118,7 @@ test.describe('Node library sidebar V2', () => {
|
||||
|
||||
// Reka UI DropdownMenuRadioItem renders with role="menuitemradio"
|
||||
const options = comfyPage.page.getByRole('menuitemradio')
|
||||
await expect(options.first()).toBeVisible({ timeout: 3000 })
|
||||
await expect(options.first()).toBeVisible()
|
||||
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -289,9 +289,7 @@ test.describe('Workflows sidebar', () => {
|
||||
)
|
||||
await closeButton.click()
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames(), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['*Unsaved Workflow'])
|
||||
})
|
||||
|
||||
@@ -370,7 +368,7 @@ test.describe('Workflows sidebar', () => {
|
||||
// Wait for workflow to appear in Browse section after sync
|
||||
const workflowItem =
|
||||
comfyPage.menu.workflowsTab.getPersistedItem('workflow1')
|
||||
await expect(workflowItem).toBeVisible({ timeout: 3000 })
|
||||
await expect(workflowItem).toBeVisible()
|
||||
|
||||
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
|
||||
@@ -219,9 +219,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport))
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
@@ -237,9 +235,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport))
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
@@ -263,13 +259,11 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 5_000 }
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
@@ -314,12 +308,10 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId),
|
||||
{ timeout: 2_000 }
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
@@ -350,20 +342,18 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' &&
|
||||
node.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) return { exists: false, progress: null }
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' &&
|
||||
node.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) return { exists: false, progress: null }
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
}),
|
||||
{ timeout: 5_000 }
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
)
|
||||
.toEqual({ exists: true, progress: undefined })
|
||||
})
|
||||
|
||||
@@ -15,9 +15,7 @@ async function expectPromotedWidgetNamesToContain(
|
||||
widgetName: string
|
||||
) {
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, nodeId), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, nodeId))
|
||||
.toContain(widgetName)
|
||||
}
|
||||
|
||||
@@ -27,9 +25,7 @@ async function expectPromotedWidgetCountToBeGreaterThan(
|
||||
count: number
|
||||
) {
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, nodeId), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, nodeId))
|
||||
.toBeGreaterThan(count)
|
||||
}
|
||||
|
||||
@@ -288,9 +284,7 @@ test.describe(
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
})
|
||||
@@ -332,7 +326,7 @@ test.describe(
|
||||
.locator('.p-contextmenu')
|
||||
.locator('text=Promote Widget')
|
||||
|
||||
await expect(promoteEntry.first()).toBeVisible({ timeout: 5000 })
|
||||
await expect(promoteEntry.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -536,9 +530,7 @@ test.describe(
|
||||
expectedNames.splice(removedIndex, 1)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
.toEqual(expectedNames)
|
||||
})
|
||||
|
||||
@@ -553,9 +545,7 @@ test.describe(
|
||||
|
||||
let initialWidgetCount = 0
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeGreaterThan(0)
|
||||
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
@@ -571,9 +561,7 @@ test.describe(
|
||||
|
||||
// Widget count should be reduced
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
|
||||
timeout: 5000
|
||||
})
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,13 +22,11 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
}, nodeId)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
}),
|
||||
{ timeout: 5_000 }
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
@@ -149,7 +147,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
)
|
||||
|
||||
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
await expect(visibleWidgets).toHaveCount(2, { timeout: 5_000 })
|
||||
await expect(visibleWidgets).toHaveCount(2)
|
||||
const parentCount = await visibleWidgets.count()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
@@ -23,7 +23,7 @@ async function exitSubgraphAndPublish(
|
||||
name: blueprintName
|
||||
})
|
||||
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5_000 })
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1)
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
|
||||
// Expect the templates dialog to be shown
|
||||
await expect(comfyPage.templates.content).toBeVisible({ timeout: 5000 })
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
@@ -305,7 +305,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-short-description"]'
|
||||
)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
).toBeVisible()
|
||||
|
||||
// Verify all three cards with different descriptions are visible
|
||||
const shortDescCard = comfyPage.page.locator(
|
||||
@@ -399,7 +399,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
const taggedCard = comfyPage.page.getByTestId(
|
||||
TestIds.templates.workflowCard('tagged-template')
|
||||
)
|
||||
await expect(taggedCard).toBeVisible({ timeout: 5000 })
|
||||
await expect(taggedCard).toBeVisible()
|
||||
await expect(taggedCard.getByText('Relight')).toBeVisible()
|
||||
await expect(taggedCard.getByText('Image Edit')).toBeVisible()
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ test.describe('Workflow tabs', () => {
|
||||
const contextMenu = comfyPage.page.locator(
|
||||
'[role="menu"][data-state="open"]'
|
||||
)
|
||||
await expect(contextMenu).toBeVisible({ timeout: 5000 })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await expect(
|
||||
contextMenu.getByRole('menuitem', { name: /Close Tab/i }).first()
|
||||
@@ -89,7 +89,7 @@ test.describe('Workflow tabs', () => {
|
||||
const contextMenu = comfyPage.page.locator(
|
||||
'[role="menu"][data-state="open"]'
|
||||
)
|
||||
await expect(contextMenu).toBeVisible({ timeout: 5000 })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await contextMenu
|
||||
.getByRole('menuitem', { name: /Close Tab/i })
|
||||
@@ -123,7 +123,7 @@ test.describe('Workflow tabs', () => {
|
||||
|
||||
// WorkflowTab renders "•" when the workflow has unsaved changes
|
||||
const activeTab = topbar.getActiveTab()
|
||||
await expect(activeTab.locator('text=•')).toBeVisible({ timeout: 5000 })
|
||||
await expect(activeTab.locator('text=•')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Multiple tabs can be created, switched, and closed', async ({
|
||||
|
||||
@@ -217,14 +217,12 @@ test.describe('Vue Node Context Menu', () => {
|
||||
if (!loadImageNode) throw new Error('Load Image node not found')
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(
|
||||
(nodeId) =>
|
||||
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
|
||||
loadImageNode.id
|
||||
),
|
||||
{ timeout: 5_000 }
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(nodeId) =>
|
||||
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
|
||||
loadImageNode.id
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -102,7 +102,7 @@ test.describe('Vue Nodes - Delete Key Interaction', () => {
|
||||
|
||||
// Node count should remain the same
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
|
||||
@@ -132,9 +132,7 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
|
||||
.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -156,9 +154,7 @@ test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
|
||||
.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,11 +134,7 @@ test.describe('Workflow Persistence', () => {
|
||||
await tab.switchToWorkflow('outputs-test')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
await expect
|
||||
.poll(() => getNodeOutputImageCount(comfyPage, nodeId), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(1)
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
|
||||
})
|
||||
|
||||
test('Loading a new workflow cleanly replaces the previous graph', async ({
|
||||
@@ -191,9 +187,7 @@ test.describe('Workflow Persistence', () => {
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
await expect
|
||||
.poll(() => getWidgetValueSnapshot(comfyPage), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.poll(() => getWidgetValueSnapshot(comfyPage))
|
||||
.toEqual(widgetValuesBefore)
|
||||
})
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe('Workflow Tab Thumbnails', { tag: '@workflow' }, () => {
|
||||
|
||||
const popover = comfyPage.page.locator('.workflow-popover-fade')
|
||||
await expect(popover).toHaveCount(1)
|
||||
await expect(popover).toBeVisible({ timeout: 500 })
|
||||
await expect(popover).toBeVisible()
|
||||
if (name) {
|
||||
await expect(popover).toContainText(name)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -114,4 +114,21 @@ describe('DomWidget disabled style', () => {
|
||||
expect(root.style.pointerEvents).toBe('none')
|
||||
expect(root.style.opacity).toBe('0.5')
|
||||
})
|
||||
|
||||
it('disables pointer events when widget is not visible', async () => {
|
||||
const widgetState = createWidgetState(false)
|
||||
widgetState.visible = false
|
||||
const { container } = render(DomWidget, {
|
||||
props: {
|
||||
widgetState
|
||||
}
|
||||
})
|
||||
|
||||
widgetState.zIndex = 3
|
||||
await nextTick()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const root = container.querySelector('.dom-widget') as HTMLElement
|
||||
expect(root.style.pointerEvents).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,7 +113,10 @@ function composeStyle() {
|
||||
...positionStyle.value,
|
||||
...(enableDomClipping.value ? clippingStyle.value : {}),
|
||||
zIndex: widgetState.zIndex,
|
||||
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
|
||||
pointerEvents:
|
||||
!widgetState.visible || widgetState.readonly || isDisabled
|
||||
? 'none'
|
||||
: 'auto',
|
||||
opacity: isDisabled ? 0.5 : 1
|
||||
}
|
||||
}
|
||||
|
||||
668
src/composables/useImageCrop.test.ts
Normal file
668
src/composables/useImageCrop.test.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useResizeObserver: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => {
|
||||
const getNodeImageUrls = vi.fn()
|
||||
return {
|
||||
useNodeOutputStore: () => ({
|
||||
getNodeImageUrls,
|
||||
nodeOutputs: {},
|
||||
nodePreviewImages: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import type { useImageCrop as UseImageCropFn } from './useImageCrop'
|
||||
import { useImageCrop } from './useImageCrop'
|
||||
|
||||
function createMockNode(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
getInputNode: vi.fn().mockReturnValue(null),
|
||||
getInputLink: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createPointerEvent(
|
||||
type: string,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
): PointerEvent {
|
||||
const event = new PointerEvent(type, { clientX, clientY })
|
||||
Object.defineProperty(event, 'target', {
|
||||
value: {
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn()
|
||||
}
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
function createOptions(modelValue?: Partial<Bounds>) {
|
||||
const bounds: Bounds = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
...modelValue
|
||||
}
|
||||
return {
|
||||
imageEl: ref<HTMLImageElement | null>(null),
|
||||
containerEl: ref<HTMLDivElement | null>(null),
|
||||
modelValue: ref(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
// Single wrapper component used to trigger onMounted lifecycle
|
||||
const Wrapper = defineComponent({
|
||||
props: { run: { type: Function, required: true } },
|
||||
setup(props) {
|
||||
props.run()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
|
||||
function mountComposable(
|
||||
options: ReturnType<typeof createOptions>,
|
||||
nodeId: NodeId = 1
|
||||
) {
|
||||
let result!: ReturnType<typeof UseImageCropFn>
|
||||
|
||||
mount(Wrapper, {
|
||||
props: { run: () => (result = useImageCrop(nodeId, options)) }
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function setup(modelValue?: Partial<Bounds>, nodeId: NodeId = 1) {
|
||||
const options = createOptions(modelValue)
|
||||
const result = mountComposable(options, nodeId)
|
||||
return { ...result, options }
|
||||
}
|
||||
|
||||
function setupWithImage(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
modelValue?: Partial<Bounds>
|
||||
) {
|
||||
const options = createOptions({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
...modelValue
|
||||
})
|
||||
|
||||
options.imageEl.value = {
|
||||
naturalWidth,
|
||||
naturalHeight
|
||||
} as HTMLImageElement
|
||||
|
||||
options.containerEl.value = {
|
||||
clientWidth: containerWidth,
|
||||
clientHeight: containerHeight,
|
||||
getBoundingClientRect: () => ({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: containerWidth,
|
||||
bottom: containerHeight,
|
||||
toJSON: () => {}
|
||||
})
|
||||
} as unknown as HTMLDivElement
|
||||
|
||||
const result = mountComposable(options)
|
||||
result.handleImageLoad()
|
||||
return result
|
||||
}
|
||||
|
||||
describe('useImageCrop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(resolveNode).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('crop computed properties', () => {
|
||||
it('reads crop dimensions from modelValue', () => {
|
||||
const { cropX, cropY, cropWidth, cropHeight } = setup({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 200,
|
||||
height: 300
|
||||
})
|
||||
|
||||
expect(cropX.value).toBe(10)
|
||||
expect(cropY.value).toBe(20)
|
||||
expect(cropWidth.value).toBe(200)
|
||||
expect(cropHeight.value).toBe(300)
|
||||
})
|
||||
|
||||
it('writes crop dimensions back to modelValue', () => {
|
||||
const { cropX, cropY, cropWidth, cropHeight, options } = setup()
|
||||
|
||||
cropX.value = 50
|
||||
cropY.value = 60
|
||||
cropWidth.value = 100
|
||||
cropHeight.value = 150
|
||||
|
||||
expect(options.modelValue.value).toMatchObject({
|
||||
x: 50,
|
||||
y: 60,
|
||||
width: 100,
|
||||
height: 150
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults cropWidth/cropHeight to 512 when modelValue is 0', () => {
|
||||
const { cropWidth, cropHeight } = setup({ width: 0, height: 0 })
|
||||
|
||||
expect(cropWidth.value).toBe(512)
|
||||
expect(cropHeight.value).toBe(512)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cropBoxStyle', () => {
|
||||
it('computes style from crop state and scale factor', () => {
|
||||
const { cropBoxStyle } = setup({
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150
|
||||
})
|
||||
|
||||
// With default scaleFactor=1 and offsets=0, border=2
|
||||
expect(cropBoxStyle.value).toMatchObject({
|
||||
left: `${100 * 1 - 2}px`,
|
||||
top: `${50 * 1 - 2}px`,
|
||||
width: `${200 * 1}px`,
|
||||
height: `${150 * 1}px`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectedRatio', () => {
|
||||
it('defaults to custom when no ratio is locked', () => {
|
||||
const { selectedRatio } = setup()
|
||||
expect(selectedRatio.value).toBe('custom')
|
||||
})
|
||||
|
||||
it('sets lockedRatio when selecting a predefined ratio', () => {
|
||||
const { selectedRatio, isLockEnabled } = setup()
|
||||
|
||||
selectedRatio.value = '16:9'
|
||||
|
||||
expect(isLockEnabled.value).toBe(true)
|
||||
expect(selectedRatio.value).toBe('16:9')
|
||||
})
|
||||
|
||||
it('clears lockedRatio when selecting custom', () => {
|
||||
const { selectedRatio, isLockEnabled } = setup()
|
||||
|
||||
selectedRatio.value = '1:1'
|
||||
expect(isLockEnabled.value).toBe(true)
|
||||
|
||||
selectedRatio.value = 'custom'
|
||||
expect(isLockEnabled.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLockEnabled', () => {
|
||||
it('derives locked ratio from current crop dimensions', () => {
|
||||
const { isLockEnabled, selectedRatio } = setup({
|
||||
width: 400,
|
||||
height: 200
|
||||
})
|
||||
|
||||
isLockEnabled.value = true
|
||||
|
||||
// Should compute ratio as 400/200 = 2, not match any preset
|
||||
expect(selectedRatio.value).toBe('custom')
|
||||
expect(isLockEnabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('unlocks when set to false', () => {
|
||||
const { isLockEnabled, selectedRatio } = setup()
|
||||
|
||||
isLockEnabled.value = true
|
||||
isLockEnabled.value = false
|
||||
|
||||
expect(isLockEnabled.value).toBe(false)
|
||||
expect(selectedRatio.value).toBe('custom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyLockedRatio (via selectedRatio setter)', () => {
|
||||
it('adjusts height to match 1:1 ratio when image is loaded', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 400
|
||||
})
|
||||
|
||||
result.selectedRatio.value = '1:1'
|
||||
|
||||
expect(result.cropWidth.value).toBe(200)
|
||||
expect(result.cropHeight.value).toBe(200)
|
||||
})
|
||||
|
||||
it('clamps height and adjusts width at naturalHeight boundary', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 0,
|
||||
y: 900,
|
||||
width: 200,
|
||||
height: 100
|
||||
})
|
||||
|
||||
result.selectedRatio.value = '1:1'
|
||||
|
||||
// Only 100px remain (1000 - 900), so height=100, width=100
|
||||
expect(result.cropHeight.value).toBeLessThanOrEqual(100)
|
||||
expect(result.cropWidth.value).toBe(result.cropHeight.value)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resizeHandles', () => {
|
||||
it('returns all 8 handles when ratio is unlocked', () => {
|
||||
const { resizeHandles } = setup()
|
||||
|
||||
expect(resizeHandles.value).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('returns only corner handles when ratio is locked', () => {
|
||||
const { resizeHandles, isLockEnabled } = setup()
|
||||
|
||||
isLockEnabled.value = true
|
||||
|
||||
const directions = resizeHandles.value.map((h) => h.direction)
|
||||
expect(directions).toEqual(['nw', 'ne', 'sw', 'se'])
|
||||
expect(resizeHandles.value).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleImageLoad', () => {
|
||||
it('sets isLoading to false', () => {
|
||||
const { isLoading, handleImageLoad } = setup()
|
||||
|
||||
isLoading.value = true
|
||||
handleImageLoad()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleImageError', () => {
|
||||
it('sets isLoading to false and clears imageUrl', () => {
|
||||
const { isLoading, imageUrl, handleImageError } = setup()
|
||||
|
||||
isLoading.value = true
|
||||
imageUrl.value = 'http://example.com/img.png'
|
||||
handleImageError()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDragStart/Move/End', () => {
|
||||
it('does nothing when imageUrl is null', () => {
|
||||
const { handleDragStart, cropX } = setup({ x: 100 })
|
||||
|
||||
handleDragStart(createPointerEvent('pointerdown', 50, 50))
|
||||
|
||||
expect(cropX.value).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores drag move when not dragging', () => {
|
||||
const { handleDragMove, cropX } = setup({ x: 100 })
|
||||
|
||||
handleDragMove(createPointerEvent('pointermove', 200, 200))
|
||||
|
||||
expect(cropX.value).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores drag end when not dragging', () => {
|
||||
const { handleDragEnd } = setup()
|
||||
|
||||
handleDragEnd(createPointerEvent('pointerup', 200, 200))
|
||||
})
|
||||
|
||||
it('moves crop box by pointer delta', () => {
|
||||
// 1000x1000 image in 500x500 container: effectiveScale = 0.5
|
||||
// pointer delta of 50px -> natural delta of 50/0.5 = 100
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
|
||||
result.handleDragMove(createPointerEvent('pointermove', 50, 30))
|
||||
|
||||
expect(result.cropX.value).toBe(200)
|
||||
expect(result.cropY.value).toBe(160)
|
||||
|
||||
result.handleDragEnd(createPointerEvent('pointerup', 50, 30))
|
||||
})
|
||||
|
||||
it('clamps drag to image boundaries', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 700,
|
||||
y: 700,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
|
||||
// delta = 500/0.5 = 1000, so x would be 1700 but max is 800
|
||||
result.handleDragMove(createPointerEvent('pointermove', 500, 500))
|
||||
|
||||
expect(result.cropX.value).toBe(800)
|
||||
expect(result.cropY.value).toBe(800)
|
||||
|
||||
result.handleDragEnd(createPointerEvent('pointerup', 500, 500))
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleResizeStart/Move/End', () => {
|
||||
it('does nothing when imageUrl is null', () => {
|
||||
const { handleResizeStart, cropWidth } = setup({ width: 200 })
|
||||
|
||||
handleResizeStart(createPointerEvent('pointerdown', 50, 50), 'se')
|
||||
|
||||
expect(cropWidth.value).toBe(200)
|
||||
})
|
||||
|
||||
it('ignores resize move when not resizing', () => {
|
||||
const { handleResizeMove, cropWidth } = setup({ width: 200 })
|
||||
|
||||
handleResizeMove(createPointerEvent('pointermove', 300, 300))
|
||||
|
||||
expect(cropWidth.value).toBe(200)
|
||||
})
|
||||
|
||||
it('ignores resize end when not resizing', () => {
|
||||
const { handleResizeEnd } = setup()
|
||||
|
||||
handleResizeEnd(createPointerEvent('pointerup', 200, 200))
|
||||
})
|
||||
|
||||
it('resizes from the right edge', () => {
|
||||
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 25, 0))
|
||||
|
||||
expect(result.cropWidth.value).toBe(250)
|
||||
expect(result.cropX.value).toBe(100)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 25, 0))
|
||||
})
|
||||
|
||||
it('resizes from the bottom edge', () => {
|
||||
// effectiveScale = 0.5, so pointer delta 40px -> natural 80px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(
|
||||
createPointerEvent('pointerdown', 0, 0),
|
||||
'bottom'
|
||||
)
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 0, 40))
|
||||
|
||||
expect(result.cropHeight.value).toBe(280)
|
||||
expect(result.cropY.value).toBe(100)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 0, 40))
|
||||
})
|
||||
|
||||
it('resizes from left edge, moving x and shrinking width', () => {
|
||||
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 200,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'left')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 50, 0))
|
||||
|
||||
// delta = 50/0.5 = 100 natural px; newX = 200+100 = 300, newW = 400-100 = 300
|
||||
expect(result.cropX.value).toBe(300)
|
||||
expect(result.cropWidth.value).toBe(300)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 50, 0))
|
||||
})
|
||||
|
||||
it('enforces MIN_CROP_SIZE when resizing', () => {
|
||||
// effectiveScale = 0.5, so pointer -200px -> natural -400px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', -200, 0))
|
||||
|
||||
// MIN_CROP_SIZE = 16
|
||||
expect(result.cropWidth.value).toBe(16)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', -200, 0))
|
||||
})
|
||||
|
||||
it('performs constrained resize with locked ratio from se corner', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
result.selectedRatio.value = '1:1'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'se')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 100, 100))
|
||||
|
||||
// Both dimensions should grow equally for 1:1 ratio
|
||||
expect(result.cropWidth.value).toBe(result.cropHeight.value)
|
||||
expect(result.cropWidth.value).toBeGreaterThan(200)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 100, 100))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputImageUrl (via imageUrl)', () => {
|
||||
it('returns null when node is not found', () => {
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns URL from nodeOutputStore when node has output', () => {
|
||||
const mockSourceNode = { isSubgraphNode: () => false }
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(store.getNodeImageUrls).mockReturnValue([
|
||||
'http://example.com/output.png'
|
||||
])
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBe('http://example.com/output.png')
|
||||
})
|
||||
|
||||
it('returns null when source node has no output', () => {
|
||||
const mockSourceNode = { isSubgraphNode: () => false }
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(store.getNodeImageUrls).mockReturnValue(undefined)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when node has no input node', () => {
|
||||
const mockNode = createMockNode()
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves subgraph node output link', () => {
|
||||
const resolvedOutputNode = { isSubgraphNode: () => false }
|
||||
const mockSourceNode = {
|
||||
isSubgraphNode: () => true,
|
||||
resolveSubgraphOutputLink: vi
|
||||
.fn()
|
||||
.mockReturnValue({ outputNode: resolvedOutputNode })
|
||||
}
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
|
||||
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(store.getNodeImageUrls).mockReturnValue([
|
||||
'http://example.com/subgraph.png'
|
||||
])
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBe('http://example.com/subgraph.png')
|
||||
})
|
||||
|
||||
it('returns null when subgraph resolution fails (no link)', () => {
|
||||
const mockSourceNode = {
|
||||
isSubgraphNode: () => true,
|
||||
resolveSubgraphOutputLink: vi.fn()
|
||||
}
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
|
||||
getInputLink: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when subgraph resolves to no output node', () => {
|
||||
const mockSourceNode = {
|
||||
isSubgraphNode: () => true,
|
||||
resolveSubgraphOutputLink: vi.fn().mockReturnValue({ outputNode: null })
|
||||
}
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
|
||||
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateDisplayedDimensions (via handleImageLoad)', () => {
|
||||
it('calculates scale for landscape image in smaller container', () => {
|
||||
const result = setupWithImage(1000, 500, 500, 400)
|
||||
|
||||
// Landscape image: imageAspect=2 > containerAspect=1.25
|
||||
// width-constrained: displayedWidth=500, scaleFactor=0.5
|
||||
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.5}px`)
|
||||
})
|
||||
|
||||
it('calculates scale for portrait image in wider container', () => {
|
||||
const result = setupWithImage(500, 1000, 600, 400)
|
||||
|
||||
// Portrait: imageAspect=0.5 < containerAspect=1.5
|
||||
// height-constrained: displayedWidth=200, scaleFactor=0.4
|
||||
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.4}px`)
|
||||
})
|
||||
|
||||
it('handles zero natural dimensions gracefully', () => {
|
||||
const result = setupWithImage(0, 0, 500, 400, {
|
||||
width: 512,
|
||||
height: 512
|
||||
})
|
||||
|
||||
// scaleFactor should default to 1
|
||||
expect(result.cropBoxStyle.value.width).toBe(`${512}px`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('calls resolveNode with the given nodeId', () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
setup()
|
||||
|
||||
expect(resolveNode).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('sets node to null when resolveNode returns undefined', () => {
|
||||
vi.mocked(resolveNode).mockReturnValue(undefined)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
|
||||
expect(resolveNode).toHaveBeenCalledWith(1)
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -323,6 +323,38 @@ describe('useGLSLPreview', () => {
|
||||
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses custom resolution when size_mode is custom', async () => {
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
store._widgetMap.set('size_mode', { value: 'custom' })
|
||||
store._widgetMap.set('size_mode.width', { value: 800 })
|
||||
store._widgetMap.set('size_mode.height', { value: 600 })
|
||||
|
||||
const node = createMockNode()
|
||||
await setupAndRender(node)
|
||||
|
||||
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(800, 600)
|
||||
|
||||
store._widgetMap.delete('size_mode')
|
||||
store._widgetMap.delete('size_mode.width')
|
||||
store._widgetMap.delete('size_mode.height')
|
||||
})
|
||||
|
||||
it('uses default resolution when size_mode is not custom', async () => {
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
store._widgetMap.set('size_mode', { value: 'from_input' })
|
||||
|
||||
const node = createMockNode()
|
||||
await setupAndRender(node)
|
||||
|
||||
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(512, 512)
|
||||
|
||||
store._widgetMap.delete('size_mode')
|
||||
})
|
||||
|
||||
it('disposes renderer and cancels debounce on cleanup', async () => {
|
||||
const node = createMockNode()
|
||||
const { dispose } = await setupAndRender(node)
|
||||
|
||||
@@ -282,7 +282,44 @@ function createInnerPreview(
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomResolution(): [number, number] | null {
|
||||
const gId = graphId.value
|
||||
if (!gId) return null
|
||||
|
||||
const sizeModeNodeId = innerGLSLNode
|
||||
? (innerGLSLNode.id as NodeId)
|
||||
: nodeId.value
|
||||
if (sizeModeNodeId == null) return null
|
||||
|
||||
const sizeMode = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode'
|
||||
)
|
||||
if (sizeMode?.value !== 'custom') return null
|
||||
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (!widthWidget || !heightWidget) return null
|
||||
|
||||
return clampResolution(
|
||||
normalizeDimension(widthWidget.value),
|
||||
normalizeDimension(heightWidget.value)
|
||||
)
|
||||
}
|
||||
|
||||
function getResolution(): [number, number] {
|
||||
const custom = getCustomResolution()
|
||||
if (custom) return custom
|
||||
|
||||
const node = nodeRef.value
|
||||
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
|
||||
@@ -325,27 +362,6 @@ function createInnerPreview(
|
||||
}
|
||||
}
|
||||
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (gId && nId != null) {
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (widthWidget && heightWidget) {
|
||||
return clampResolution(
|
||||
normalizeDimension(widthWidget.value),
|
||||
normalizeDimension(heightWidget.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
}
|
||||
|
||||
|
||||
115
src/renderer/glsl/useGLSLUniforms.test.ts
Normal file
115
src/renderer/glsl/useGLSLUniforms.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
|
||||
import {
|
||||
extractUniformSources,
|
||||
toNumber
|
||||
} from '@/renderer/glsl/useGLSLUniforms'
|
||||
|
||||
function createMockSubgraph(
|
||||
links: Record<number, { origin_id: number; origin_slot: number }>,
|
||||
nodes: Record<
|
||||
number,
|
||||
{ id: number; widgets: Array<{ name: string; value: unknown }> }
|
||||
>
|
||||
) {
|
||||
return fromAny<Subgraph, unknown>({
|
||||
getLink: (id: number) => links[id] ?? null,
|
||||
getNodeById: (id: number) => nodes[id] ?? null
|
||||
})
|
||||
}
|
||||
|
||||
describe('extractUniformSources', () => {
|
||||
it('uses origin_slot to select the correct widget from source node', () => {
|
||||
const glslNode = fromAny<LGraphNode, unknown>({
|
||||
inputs: [
|
||||
{ name: 'ints.u_int0', link: 1 },
|
||||
{ name: 'ints.u_int1', link: 2 }
|
||||
]
|
||||
})
|
||||
|
||||
const subgraph = createMockSubgraph(
|
||||
{
|
||||
1: { origin_id: 10, origin_slot: 1 },
|
||||
2: { origin_id: 20, origin_slot: 0 }
|
||||
},
|
||||
{
|
||||
10: {
|
||||
id: 10,
|
||||
widgets: [
|
||||
{ name: 'choice', value: 'Master' },
|
||||
{ name: 'index', value: 0 }
|
||||
]
|
||||
},
|
||||
20: {
|
||||
id: 20,
|
||||
widgets: [{ name: 'value', value: 42 }]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const result = extractUniformSources(glslNode, subgraph)
|
||||
|
||||
expect(result.ints[0].widgetName).toBe('index')
|
||||
expect(result.ints[1].widgetName).toBe('value')
|
||||
})
|
||||
|
||||
it('skips source when origin_slot exceeds widget count', () => {
|
||||
const glslNode = fromAny<LGraphNode, unknown>({
|
||||
inputs: [{ name: 'floats.u_float0', link: 1 }]
|
||||
})
|
||||
|
||||
const subgraph = createMockSubgraph(
|
||||
{ 1: { origin_id: 10, origin_slot: 5 } },
|
||||
{ 10: { id: 10, widgets: [{ name: 'value', value: 3.14 }] } }
|
||||
)
|
||||
|
||||
const result = extractUniformSources(glslNode, subgraph)
|
||||
|
||||
expect(result.floats).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('provides directValue getter that reads from the widget', () => {
|
||||
const indexWidget = {
|
||||
name: 'index',
|
||||
get value() {
|
||||
return choiceWidget.value === 'Reds' ? 1 : 0
|
||||
}
|
||||
}
|
||||
const choiceWidget = { name: 'choice', value: 'Master' }
|
||||
|
||||
const glslNode = fromAny<LGraphNode, unknown>({
|
||||
inputs: [{ name: 'ints.u_int0', link: 1 }]
|
||||
})
|
||||
|
||||
const subgraph = createMockSubgraph(
|
||||
{ 1: { origin_id: 10, origin_slot: 1 } },
|
||||
{ 10: { id: 10, widgets: [choiceWidget, indexWidget] } }
|
||||
)
|
||||
|
||||
const result = extractUniformSources(glslNode, subgraph)
|
||||
|
||||
expect(result.ints[0].directValue()).toBe(0)
|
||||
|
||||
choiceWidget.value = 'Reds'
|
||||
expect(result.ints[0].directValue()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toNumber', () => {
|
||||
it('coerces hex color strings via hexToInt', () => {
|
||||
expect(toNumber('#45edf5')).toBe(0x45edf5)
|
||||
})
|
||||
|
||||
it('coerces plain numeric values', () => {
|
||||
expect(toNumber(42)).toBe(42)
|
||||
expect(toNumber('10')).toBe(10)
|
||||
})
|
||||
|
||||
it('returns 0 for non-numeric strings', () => {
|
||||
expect(toNumber('Master')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { isCurveData } from '@/components/curve/curveUtils'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import { hexToInt } from '@/utils/colorUtil'
|
||||
|
||||
interface AutogrowGroup {
|
||||
max: number
|
||||
@@ -20,6 +21,8 @@ interface AutogrowGroup {
|
||||
interface UniformSource {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
/** Fallback getter for widgets not registered in widgetValueStore (e.g. hidden computed widgets). */
|
||||
directValue: () => unknown
|
||||
}
|
||||
|
||||
interface UniformSources {
|
||||
@@ -78,16 +81,19 @@ export function extractUniformSources(
|
||||
if (!link || link.origin_id === SUBGRAPH_INPUT_ID) continue
|
||||
|
||||
const sourceNode = subgraph.getNodeById(link.origin_id)
|
||||
if (!sourceNode?.widgets?.[0]) continue
|
||||
if (!sourceNode?.widgets?.length) continue
|
||||
|
||||
const inputName = input.name ?? ''
|
||||
const dotIndex = inputName.indexOf('.')
|
||||
if (dotIndex === -1) continue
|
||||
|
||||
const prefix = inputName.slice(0, dotIndex)
|
||||
if (link.origin_slot >= sourceNode.widgets.length) continue
|
||||
const widget = sourceNode.widgets[link.origin_slot]
|
||||
const source: UniformSource = {
|
||||
nodeId: sourceNode.id as NodeId,
|
||||
widgetName: sourceNode.widgets[0].name
|
||||
widgetName: widget.name,
|
||||
directValue: () => widget.value
|
||||
}
|
||||
|
||||
if (prefix === 'floats') floats.push(source)
|
||||
@@ -99,6 +105,11 @@ export function extractUniformSources(
|
||||
return { floats, ints, bools, curves }
|
||||
}
|
||||
|
||||
export function toNumber(v: unknown): number {
|
||||
if (typeof v === 'string' && v.startsWith('#')) return hexToInt(v)
|
||||
return Number(v) || 0
|
||||
}
|
||||
|
||||
export function useGLSLUniforms(
|
||||
graphId: ComputedRef<UUID | undefined>,
|
||||
nodeId: ComputedRef<NodeId | undefined>,
|
||||
@@ -120,9 +131,9 @@ export function useGLSLUniforms(
|
||||
if (!gId) return []
|
||||
|
||||
if (subgraphSources) {
|
||||
return subgraphSources.map(({ nodeId: nId, widgetName }) => {
|
||||
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
return coerce(widget?.value ?? defaultValue)
|
||||
return coerce(widget?.value ?? directValue() ?? defaultValue)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,19 +153,24 @@ export function useGLSLUniforms(
|
||||
const slot = node.inputs?.findIndex((inp) => inp.name === inputName)
|
||||
if (slot == null || slot < 0) break
|
||||
|
||||
const link = node.getInputLink(slot)
|
||||
if (!link) break
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode) break
|
||||
const upstreamWidgets = widgetValueStore.getNodeWidgets(
|
||||
gId,
|
||||
upstreamNode.id as NodeId
|
||||
)
|
||||
if (upstreamWidgets.length === 0) break
|
||||
values.push(coerce(upstreamWidgets[0].value))
|
||||
if (
|
||||
upstreamWidgets.length === 0 ||
|
||||
link.origin_slot >= upstreamWidgets.length
|
||||
)
|
||||
break
|
||||
values.push(coerce(upstreamWidgets[link.origin_slot].value))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
const toNumber = (v: unknown): number => Number(v) || 0
|
||||
const toBool = (v: unknown): boolean => Boolean(v)
|
||||
|
||||
const floatValues = computed(() =>
|
||||
@@ -197,11 +213,10 @@ export function useGLSLUniforms(
|
||||
const sources = uniformSources.value?.curves
|
||||
if (sources && sources.length > 0) {
|
||||
return sources
|
||||
.map(({ nodeId: nId, widgetName }) => {
|
||||
.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
return widget && isCurveData(widget.value)
|
||||
? (widget.value as CurveData)
|
||||
: null
|
||||
const value = widget?.value ?? directValue()
|
||||
return isCurveData(value) ? (value as CurveData) : null
|
||||
})
|
||||
.filter((v): v is CurveData => v !== null)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
adjustColor,
|
||||
hexToHsva,
|
||||
hexToInt,
|
||||
hexToRgb,
|
||||
hsbToRgb,
|
||||
hsvaToHex,
|
||||
@@ -95,6 +96,20 @@ describe('colorUtil conversions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hexToInt', () => {
|
||||
it('converts 6-digit hex to packed integer', () => {
|
||||
expect(hexToInt('#ff0000')).toBe(0xff0000)
|
||||
expect(hexToInt('#00ff00')).toBe(0x00ff00)
|
||||
expect(hexToInt('#45edf5')).toBe(0x45edf5)
|
||||
expect(hexToInt('#000000')).toBe(0)
|
||||
})
|
||||
|
||||
it('converts 3-digit hex to packed integer', () => {
|
||||
expect(hexToInt('#fff')).toBe(0xffffff)
|
||||
expect(hexToInt('#f00')).toBe(0xff0000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseToRgb', () => {
|
||||
it('parses #hex', () => {
|
||||
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
|
||||
|
||||
@@ -82,6 +82,11 @@ export function hexToRgb(hex: string): RGB {
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
export function hexToInt(hex: string): number {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return (r << 16) | (g << 8) | b
|
||||
}
|
||||
|
||||
export function rgbToHex({ r, g, b }: RGB): string {
|
||||
const toHex = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round(n)))
|
||||
|
||||
Reference in New Issue
Block a user