Compare commits

..

9 Commits

Author SHA1 Message Date
bymyself
c427749c9a test: add unit tests for useImageCrop composable 2026-04-10 15:14:33 -07:00
Alexander Brown
0353524e6f refactor: standardize Page Object locators as public readonly instead of getters (#11135)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Convert ~120 getter-based locators across 18 browser test fixture
files to `public readonly` constructor-assigned properties
- Removes unnecessary indirection, makes object shape explicit, and
improves IDE auto-complete / type inference
- Keeps lazy-init getters (`??=`), computed properties, and `private get
page()` convenience accessors as getters

## Changes

**`browser_tests/fixtures/components/`** (6 files):
`ComfyNodeSearchBox`, `ContextMenu`, `SettingDialog`, `SignInDialog`,
`SidebarTab` (all 6 classes), `Topbar`

**`browser_tests/fixtures/`** (4 files): `ComfyPage` (ComfyMenu.buttons,
ComfyPage.visibleToasts), `UserSelectPage`, `ComfyMouse`,
`VueNodeHelpers`

**`browser_tests/fixtures/helpers/`** (7 files): `AppModeHelper`,
`BuilderFooterHelper`, `BuilderSaveAsHelper`, `BuilderSelectHelper`,
`BuilderStepsHelper`, `ToastHelper`, `NodeOperationsHelper`

**`browser_tests/fixtures/utils/`** (1 file): `vueNodeFixtures`

## Validation

- `pnpm typecheck` 
- `pnpm typecheck:browser` 
- `pnpm exec eslint browser_tests/fixtures/` 
- All pre-commit hooks pass (oxfmt, oxlint, eslint, typecheck,
typecheck:browser) 
- No visual/manual verification needed — changes are test fixture
locator declarations only (no UI or runtime behavior change)

Fixes #11131

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11135-refactor-standardize-Page-Object-locators-as-public-readonly-instead-of-getters-33e6d73d3650819690cbc639f3d30daf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-10 21:10:17 +00:00
Alexander Brown
f1bb756929 fix: remove redundant and counterproductive e2e timeout overrides (#11110)
## Summary

Alright, alright, alright. These e2e tests have been runnin' around like
they're late for somethin', settin' tight little timeouts like the
world's gonna end in 250 milliseconds. Man, you gotta *breathe*. Let the
framework do its thing. Go slow to go fast, that's what I always say.

## Changes

- **What**: Removed ~120 redundant timeout overrides from auto-retrying
Playwright assertions (`toBeVisible`, `toBeHidden`, `toHaveCount`,
`toBeEnabled`, `toHaveAttribute`, `toContainText`, `expect.poll`) where
5000ms is already the default. Also removed sub-5s timeouts (1s, 2s, 3s)
that were just *begging* for flaky failures — like wearin' a belt and
suspenders and also holdin' your pants up with both hands. Raised the
absurdly short timeouts in `customMatchers.ts` (250ms `toPass` → 5000ms,
256ms poll → default). Kept `timeout: 5000` on `.toPass()` calls
(defaults to 0), `.waitFor()`, `waitForRequest`, `waitForFunction`,
intentionally-short timeouts inside retry loops, and conditional
`.isVisible()/.catch()` checks — those fellas actually need the help.

## Review Focus

Every remaining timeout in the diff is there for a *reason*. The ones on
`.toPass()` stay because that API defaults to zero — it won't retry at
all without one. The ones on `.waitFor()` and `waitForRequest` stay
because those are locator actions, not auto-retrying assertions. The
intentionally-short ones inside `toPass` retry loops
(`interaction.spec.ts`) and the negative assertions (`actionbar.spec.ts`
confirming no response arrives) — those are *supposed* to be tight.

The short timeouts on regular assertions were actively *encouragin'*
flaky failures. That's like settin' your alarm for 4 AM and then gettin'
mad you're tired. Just... don't do that, man. Let things take the time
they need.

38 files, net -115 lines. Less code, more chill. That's livin'.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 19:47:20 +00:00
Christian Byrne
d70c3cbfc2 fix: remove ticket-intake skill from repo (#11127)
Removes the `.claude/skills/ticket-intake/` directory — personal
pipeline automation that doesn't belong in the shared repository.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11127-fix-remove-ticket-intake-skill-from-repo-33e6d73d365081849f00d7f988ac17e5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-10 11:42:47 -07:00
pythongosssss
e38dd1efae test: App mode - Add test for cross-workflow input corruption (#10944)
## Summary

Adds test for the issue where inputs from one workflow would overwrite
those on another after sorting

## Changes

- **What**: 
- add test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10944-test-App-mode-Add-test-for-cross-workflow-input-corruption-33b6d73d365081deab26d8f23d2318ae)
by [Unito](https://www.unito.io)
2026-04-10 17:55:33 +00:00
Comfy Org PR Bot
62779d3c51 1.44.1 (#11007)
Patch version increment to 1.44.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11007-1-44-1-33e6d73d365081e3bbd3c02a3a7ca5d0)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-10 17:35:22 +00:00
Christian Byrne
4cf160d66e fix: disable pointer events on non-visible DOM widget overlays (#11063)
## Problem

When a node with DOM widget overlays (e.g. CLIPTextEncode) is collapsed,
the overlay elements can intercept pointer events intended for the
canvas collapse toggler, making click-to-expand unreliable.

## Root Cause

`updateWidgets()` runs during `onDrawForeground` (canvas render cycle)
and sets `widgetState.visible = false` for collapsed nodes. `v-show`
then hides the element with `display: none`. However, there is a timing
gap between the canvas state change and Vue's DOM update — during this
gap the widget overlay still intercepts pointer events.

## Fix

Add `!widgetState.visible` to the `pointerEvents` condition in
`composeStyle()`. This immediately sets `pointer-events: none` when the
widget becomes invisible, preventing event interception before `v-show`
applies `display: none`.

Also restores click-to-expand in the E2E test, removing the programmatic
`node.collapse()` workaround from PR #10967.

- Fixes #11006

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11063-fix-disable-pointer-events-on-non-visible-DOM-widget-overlays-33e6d73d36508179a83cd47121cf933f)
by [Unito](https://www.unito.io)
2026-04-10 14:28:25 +00:00
pythongosssss
c0871ba219 ci: Update actions for queue merge validation (#11114)
## Summary

PR merge queue was enabled but actions were not updated to trigger on
`merge_group`:

https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue#triggering-merge-group-checks-with-github-actions

## Changes

- **What**: 
- `.github/actions/lint-format-verify/action.yml` extrated shared Verify
lint and format steps
- `.github/workflows/ci-lint-format.yaml` updated to use shared
composite action
- `.github/workflows/ci-lint-format-queue.yaml` new action that triggers
on merge_group to validate format
- `.github/workflows/ci-tests-e2e.yaml` triggers on merge_group
- `.github/workflows/ci-tests-unit.yaml` triggers on merge_group

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11114-ci-Update-actions-for-queue-merge-validation-33e6d73d3650815f8c88f40736b513ec)
by [Unito](https://www.unito.io)
2026-04-10 17:09:48 +00:00
Terry Jia
6d4fc1bb10 fix: resolve incorrect GLSL live preview for non-primitive widget types (#11010)
## Summary

Three issues caused GLSL preview to diverge from backend results:

1. Uniform source resolution always read widgets[0] instead of using
link.origin_slot to select the correct widget. Added directValue
fallback for widgets not registered in widgetValueStore.

2. Hex color strings (e.g. "#45edf5") were coerced to 0 by Number().
Added hexToInt to colorUtil and used it in toNumber coercion.

3. Custom size_mode was ignored — preview always used upstream image
dimensions. Now checks size_mode widget first and respects "custom".

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11010-fix-resolve-incorrect-GLSL-live-preview-for-non-primitive-widget-types-33e6d73d36508101a76bfe8383c0c6ab)
by [Unito](https://www.unito.io)
2026-04-10 10:21:27 -04:00
79 changed files with 1722 additions and 2050 deletions

View File

@@ -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;
}
```

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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')
}
}

View File

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

View File

@@ -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()
}

View File

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

View File

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

View File

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

View File

@@ -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')
}
}

View File

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

View File

@@ -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").

View File

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

View File

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

View File

@@ -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()
}

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ function makeMatcher<T>(
? expect(value, 'Node is ' + type).not
: expect(value, 'Node is not ' + type)
assertion.toBeTruthy()
}).toPass({ timeout: 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),

View File

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

View File

@@ -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()
}

View File

@@ -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(() =>

View File

@@ -26,7 +26,7 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await appMode.steps.goToInputs()
const menu = appMode.select.getInputItemMenu('seed')
await expect(menu).toBeVisible({ 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()
})
})

View File

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

View File

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

View File

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

View File

@@ -206,8 +206,8 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
// Ensure undo reverts both changes
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed({ 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,

View File

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

View File

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

View File

@@ -250,7 +250,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ 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)
})

View File

@@ -153,7 +153,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const expanded = await select.getAttribute('aria-expanded')
if (expanded !== 'true') await select.click()
await expect(select).toHaveAttribute('aria-expanded', 'true')
}).toPass({ timeout: 3000 })
}).toPass({ timeout: 5000 })
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'

View File

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

View File

@@ -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('')
})
}

View File

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

View File

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

View File

@@ -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()
})
})

View File

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

View File

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

View File

@@ -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()
})

View File

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

View File

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

View File

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

View File

@@ -187,7 +187,7 @@ test.describe('Assets sidebar - grid view display', () => {
await tab.switchToImported()
// Wait for imported assets to render
await expect(tab.assetCards.first()).toBeVisible({ 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 }) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,7 +106,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.setup({ clearStorage: true })
// Expect the templates dialog to be shown
await expect(comfyPage.templates.content).toBeVisible({ 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()

View File

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

View File

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

View File

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

View File

@@ -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()
})
})

View File

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

View File

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

View File

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

View File

@@ -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')
})
})

View File

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

View 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()
})
})
})

View File

@@ -633,14 +633,6 @@ export function useMediaAssetActions() {
)
if (hasOutputAssets) {
const succeededOutputIds = assetArray
.filter(
(a, i) =>
getAssetType(a) === 'output' &&
results[i].status === 'fulfilled'
)
.map((a) => a.id)
assetsStore.removeHistoryItems(succeededOutputIds)
await assetsStore.updateHistory()
}
if (hasInputAssets) {

View File

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

View File

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

View 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)
})
})

View File

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

View File

@@ -421,61 +421,6 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('Deletion', () => {
it('should remove items by id from history', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(5)
store.removeHistoryItems(['prompt_1', 'prompt_3'])
expect(store.historyAssets).toHaveLength(3)
const ids = store.historyAssets.map((a) => a.id)
expect(ids).not.toContain('prompt_1')
expect(ids).not.toContain('prompt_3')
})
it('should adjust pagination offset after deletion', async () => {
const mockHistory = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
// Delete 3 items — offset should shift from 200 to 197
store.removeHistoryItems(['prompt_1', 'prompt_3', 'prompt_5'])
expect(store.historyAssets).toHaveLength(197)
const nextBatch = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(nextBatch)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 197 })
})
it('should allow re-inserting a removed item on next updateHistory', async () => {
const mockHistory = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
store.removeHistoryItems(['prompt_1'])
expect(store.historyAssets).toHaveLength(2)
// Simulate the item reappearing from the server (e.g. delete failed)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(3)
})
})
describe('Error Handling', () => {
it('should preserve existing data when loadMore fails', async () => {
// First successful load - full batch
@@ -592,142 +537,6 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('Incremental Update', () => {
it('should preserve loaded items when updateHistory is called after pagination', async () => {
// Use indices 100-299 for page1 so there's room for a "newer" item
const page1 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(100 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(300 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(store.historyAssets).toHaveLength(400)
// Index 0 → Date.now() - 0ms, which is newer than index 100+
const newJob = createMockJobItem(0)
const refreshedPage1 = [newJob, ...page1.slice(0, 199)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(401)
expect(store.historyAssets[0].id).toBe('prompt_0')
})
it('should not duplicate items when updateHistory fetches overlapping data', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([...page1])
await store.updateHistory()
expect(store.historyAssets).toHaveLength(200)
})
it('should adjust offset for loadMore after new items are prepended', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const newJobs = Array.from({ length: 5 }, (_, i) =>
createMockJobItem(5000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 195)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 205 })
})
it('should preserve drift counter when loadMore fails', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(6000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 197)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
// loadMore fails — drift counter should be preserved
vi.mocked(api.getHistory).mockRejectedValueOnce(new Error('fail'))
await store.loadMoreHistory()
// Retry loadMore — should still include the drift adjustment
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 203 })
})
it('should re-enable pagination when history grows past one page', async () => {
// Initial load with fewer than BATCH_SIZE items — pagination exhausted
const smallPage = Array.from({ length: 50 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(smallPage)
await store.updateHistory()
expect(store.hasMoreHistory).toBe(false)
// Many new jobs arrive — first page is now full (BATCH_SIZE items)
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(7000 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(fullPage)
await store.updateHistory()
expect(store.hasMoreHistory).toBe(true)
})
it('should not accumulate drift when pagination is exhausted', async () => {
const smallPage = Array.from({ length: 50 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(smallPage)
await store.updateHistory()
// Subsequent update with a few new items, still under BATCH_SIZE
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(8000 + i)
)
const refreshed = [...newJobs, ...smallPage.slice(0, 47)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshed)
await store.updateHistory()
// Re-enable pagination with a full page
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(9000 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(fullPage)
await store.updateHistory()
// loadMore should use offset 200 (no drift accumulated while exhausted)
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 200 })
})
})
describe('jobDetailView Support', () => {
it('should include outputCount and allOutputs in user_metadata', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>

View File

@@ -94,7 +94,7 @@ export const useAssetsStore = defineStore('assets', () => {
// Track assets currently being deleted (for loading overlay)
const deletingAssetIds = shallowReactive(new Set<string>())
function setAssetDeleting(assetId: string, isDeleting: boolean) {
const setAssetDeleting = (assetId: string, isDeleting: boolean) => {
if (isDeleting) {
deletingAssetIds.add(assetId)
} else {
@@ -102,7 +102,7 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
function isAssetDeleting(assetId: string): boolean {
const isAssetDeleting = (assetId: string): boolean => {
return deletingAssetIds.has(assetId)
}
@@ -110,7 +110,6 @@ export const useAssetsStore = defineStore('assets', () => {
const historyOffset = ref(0)
const hasMoreHistory = ref(true)
const isLoadingMore = ref(false)
const prependedSinceLastLoad = ref(0)
const allHistoryItems = ref<AssetItem[]>([])
@@ -134,105 +133,67 @@ export const useAssetsStore = defineStore('assets', () => {
})
/**
* Insert an asset into the sorted list at the correct position (newest first).
* Skips duplicates already tracked in loadedIds.
* @returns true if the asset was inserted, false if it was a duplicate.
* Fetch history assets with pagination support
* @param loadMore - true for pagination (append), false for initial load (replace)
*/
function insertAssetSorted(asset: AssetItem): boolean {
if (loadedIds.has(asset.id)) return false
loadedIds.add(asset.id)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
allHistoryItems.value.push(asset)
} else {
allHistoryItems.value.splice(insertIndex, 0, asset)
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
// Reset state for initial load
if (!loadMore) {
historyOffset.value = 0
hasMoreHistory.value = true
allHistoryItems.value = []
loadedIds.clear()
}
return true
}
function removeHistoryItems(ids: string[]) {
const idSet = new Set(ids)
const removedCount = allHistoryItems.value.filter((item) =>
idSet.has(item.id)
).length
allHistoryItems.value = allHistoryItems.value.filter(
(item) => !idSet.has(item.id)
)
ids.forEach((id) => loadedIds.delete(id))
historyOffset.value = Math.max(0, historyOffset.value - removedCount)
historyAssets.value = allHistoryItems.value
}
// Fetch from server with offset
const history = await api.getHistory(BATCH_SIZE, {
offset: historyOffset.value
})
function trimToMaxItems() {
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
const removed = allHistoryItems.value.splice(MAX_HISTORY_ITEMS)
removed.forEach((item) => loadedIds.delete(item.id))
}
}
/**
* Fetch the first page of history. On initial load (empty state), replaces
* all items. On subsequent calls, incrementally merges new items without
* clearing existing pagination state — fixing the "disappearing assets" bug.
*/
async function fetchHistoryIncremental(): Promise<void> {
const history = await api.getHistory(BATCH_SIZE, { offset: 0 })
// Convert JobListItems to AssetItems
const newAssets = mapHistoryToAssets(history)
if (allHistoryItems.value.length === 0) {
if (loadMore) {
// Filter out duplicates and insert in sorted order
for (const asset of newAssets) {
if (loadedIds.has(asset.id)) {
continue // Skip duplicates
}
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
// Asset is oldest, append to end
allHistoryItems.value.push(asset)
} else {
// Insert at the correct position
allHistoryItems.value.splice(insertIndex, 0, asset)
}
}
} else {
// Initial load: replace all
allHistoryItems.value = newAssets
newAssets.forEach((asset) => loadedIds.add(asset.id))
historyOffset.value = BATCH_SIZE
hasMoreHistory.value = history.length === BATCH_SIZE
} else {
let newCount = 0
for (const asset of newAssets) {
if (insertAssetSorted(asset)) newCount++
}
if (hasMoreHistory.value) {
prependedSinceLastLoad.value += newCount
} else if (history.length === BATCH_SIZE) {
hasMoreHistory.value = true
prependedSinceLastLoad.value = 0
}
}
trimToMaxItems()
}
/**
* Fetch the next page of history for infinite scroll pagination.
* Adjusts offset to account for items prepended since the last page load.
*/
async function fetchHistoryNextPage(): Promise<void> {
const driftAtRequest = prependedSinceLastLoad.value
const adjustedOffset = historyOffset.value + driftAtRequest
const history = await api.getHistory(BATCH_SIZE, {
offset: adjustedOffset
})
const newAssets = mapHistoryToAssets(history)
// Subtract only the drift captured at request time so concurrent
// updateHistory() increments during the in-flight fetch are preserved.
prependedSinceLastLoad.value = Math.max(
0,
prependedSinceLastLoad.value - driftAtRequest
)
for (const asset of newAssets) {
insertAssetSorted(asset)
}
historyOffset.value = adjustedOffset + BATCH_SIZE
// Update pagination state
historyOffset.value += BATCH_SIZE
hasMoreHistory.value = history.length === BATCH_SIZE
trimToMaxItems()
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
// Clean up Set
removed.forEach((item) => loadedIds.delete(item.id))
}
return allHistoryItems.value
}
const historyAssets = ref<AssetItem[]>([])
@@ -240,19 +201,18 @@ export const useAssetsStore = defineStore('assets', () => {
const historyError = ref<unknown>(null)
/**
* Load or refresh history assets. On first call, performs a full load.
* On subsequent calls (e.g. after job completion), incrementally merges
* new items without clearing existing pagination state.
* Initial load of history assets
*/
async function updateHistory() {
const updateHistory = async () => {
historyLoading.value = true
historyError.value = null
try {
await fetchHistoryIncremental()
await fetchHistoryAssets(false)
historyAssets.value = allHistoryItems.value
} catch (err) {
console.error('Error fetching history assets:', err)
historyError.value = err
// Keep existing data when error occurs
if (!historyAssets.value.length) {
historyAssets.value = []
}
@@ -264,18 +224,20 @@ export const useAssetsStore = defineStore('assets', () => {
/**
* Load more history items (infinite scroll)
*/
async function loadMoreHistory() {
const loadMoreHistory = async () => {
// Guard: prevent concurrent loads and check if more items available
if (!hasMoreHistory.value || isLoadingMore.value) return
isLoadingMore.value = true
historyError.value = null
try {
await fetchHistoryNextPage()
await fetchHistoryAssets(true)
historyAssets.value = allHistoryItems.value
} catch (err) {
console.error('Error loading more history:', err)
historyError.value = err
// Keep existing data when error occurs (consistent with updateHistory)
if (!historyAssets.value.length) {
historyAssets.value = []
}
@@ -779,7 +741,6 @@ export const useAssetsStore = defineStore('assets', () => {
updateInputs,
updateHistory,
loadMoreHistory,
removeHistoryItems,
// Input mapping helpers
inputAssetsByFilename,

View File

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

View File

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