Compare commits

..

73 Commits

Author SHA1 Message Date
github-actions[bot]
5dde705e14 test: add QA regression test for PR #9430 2026-03-23 16:17:50 +00:00
snomiao
8d95818a47 fix: address Copilot review feedback on QA scripts
- Enforce requestTimeoutMs via Gemini SDK requestOptions
- Add 100MB video size check before base64 encoding
- Sanitize screenshot filenames to prevent path traversal
- Sort video files by mtime for reliable rename
- Validate --mode arg against allowed values
- Add Content-Length pre-check in downloadMedia
- Add GitHub domain allowlist for media downloads (SSRF mitigation)
- Add contents:write permission and git config for report job
- Update Node.js requirement in SKILL.md from 18+ to 22+
2026-03-23 16:06:24 +00:00
snomiao
fafeecd076 fix: gracefully skip invalid pressKey values in QA recording
Instead of crashing the entire recording session when Gemini generates
an invalid key name (e.g. "mouseWheelDown"), catch the error and
continue with remaining steps.
2026-03-21 05:46:06 +00:00
snomiao
d662871dab feat: add behavior changes summary table to QA video review
Add a "Behavior Changes" table (Behavior, Before, After, Verdict)
alongside the existing timeline comparison. This gives reviewers a
quick high-level view of all behavioral differences before diving
into the frame-by-frame timeline.
2026-03-21 05:18:46 +00:00
snomiao
dcbc7ac49d fix: format before/after comparison as table in QA video review
Instruct Gemini to output the Before vs After section as a markdown
table with Time, Type, Severity, Before, After columns for easier
comparison. Update HTML template table styles with fixed layout and
column widths optimized for the 5-column comparison format.
2026-03-21 05:16:34 +00:00
snomiao
a6dbe6c5b6 fix: handle array response from Gemini in analyze-pr
Gemini Pro with responseMimeType: 'application/json' returns a JSON
array [before, after] instead of {before, after}. Handle both shapes.
2026-03-21 04:42:34 +00:00
snomiao
402602b15a fix: make analyze-pr non-blocking and log Gemini response
- Log raw Gemini response for debugging when parsing fails
- Handle possible wrapper keys in response
- Make qa-before/qa-after run even if analyze-pr fails (only gate
  on resolve-matrix success)
2026-03-21 04:42:34 +00:00
snomiao
5f7ce37807 fix: extract PR number from sno-qa-<number> branch name
When running on push events for sno-qa-* branches without an open PR,
extract the PR number from the branch name so analyze-pr can fetch
the full PR thread for analysis.
2026-03-21 04:42:34 +00:00
snomiao
02abdd4d07 feat: add analyze-pr job to QA pipeline
Add Gemini Pro-powered PR analysis that generates targeted QA guides
from the full PR thread (description, comments, screenshots, diff).
The analyze-pr job runs on lightweight ubuntu before recordings start,
producing qa-guide-before.json and qa-guide-after.json that are
downloaded by recording jobs to produce more focused test steps.

Graceful fallback: if analysis fails, recordings proceed without guides.
2026-03-21 04:42:34 +00:00
snomiao
76ff4746b3 fix: download before/after artifacts into separate directories
download-artifact@v7 merges all files flat regardless of
merge-multiple setting. Use separate path dirs (before/after)
and copy all files into the report directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:13:23 +00:00
snomiao
d0b3fd8a3c fix: set merge-multiple false for download-artifact v7
download-artifact@v7 defaults merge-multiple to true, which puts all
files flat in qa-artifacts/ instead of per-artifact subdirectories.
The merge step expects qa-artifacts/qa-before-{os}-{run}/ subdirs,
so the report directory never gets created and video review finds
no files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:06:55 +00:00
snomiao
ad35fa460a refactor: split QA into parallel before/after jobs
Instead of running before/after sequentially in a single job with
fragile git stash/checkout gymnastics, split into two independent
parallel jobs on separate runners:

  resolve-matrix → qa-before (main) ─┐
                 → qa-after  (PR)   ─┴→ report

- qa-before: uses git worktree for clean main branch build
- qa-after: normal PR build via setup-frontend
- report: downloads both artifact sets, merges, runs Gemini review

Benefits:
- Clean workspace isolation (no git checkout origin/main -- .)
- ~2x faster (parallel execution)
- Each job gets its own ComfyUI server (no shared state)
- Eliminates entire class of workspace contamination bugs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:32:50 +00:00
snomiao
01796be9fa fix: select existing user from dropdown instead of re-creating
The Pre-seed step creates qa-ci via API, so the "New user" form
shows "already exists" error. Fix by selecting the existing user
from the dropdown first, falling back to a unique username.
2026-03-21 01:50:33 +00:00
snomiao
eb39b3f369 fix: use login page directly instead of localStorage bypass
The localStorage userId bypass doesn't work because the server
validates user IDs and rejects the simple 'qa-ci' string. Instead,
detect the login page by its input fields and create a user via the
"New user" text input, which is how real users would log in.
2026-03-21 01:41:47 +00:00
snomiao
4034fe16dc fix: switch from Firefox to Chromium for WebGL canvas support
Firefox headless doesn't support WebGL, causing "getCanvas: canvas is
null" errors. Switch to Chromium which has full headless WebGL support.
Also fix login flow to wait for async router guard to settle and
create user via text input as fallback.
2026-03-21 01:32:14 +00:00
snomiao
ef6c1584d1 fix: add debug screenshot and fallback for menu button click
Add coordinate fallback when .comfy-menu-button-wrapper selector isn't
found, and capture a debug screenshot after login to diagnose what the
page looks like when the editor UI fails to render.
2026-03-21 01:28:04 +00:00
snomiao
dd5dd6d694 fix: use proper CSS selectors for menu interactions in QA recording
The openComfyMenu was clicking at hardcoded coordinates (20, 67) which
missed the menu button. Now uses .comfy-menu-button-wrapper selector
matching the browser tests. Also fixes menu item hover/click selectors
to use .p-menubar-item-label and .p-tieredmenu-item classes, and adds
a wait for the editor UI to fully load before executing test steps.
2026-03-21 01:19:42 +00:00
snomiao
65c7bf0549 fix: bypass login in QA recordings with localStorage pre-seeding
The QA recordings were stuck on the user selection screen because CI
has no existing users. Fix by pre-seeding localStorage with userId,
userName, and TutorialCompleted before navigation, plus creating a
qa-ci user via API as a fallback.
2026-03-21 01:07:28 +00:00
snomiao
bc4849f297 feat: autoplay and loop videos on QA dashboard 2026-03-21 00:26:40 +00:00
snomiao
c79f1d39d7 fix: handle flat artifact layout when no report.md exists
The normalize step couldn't create the qa-report-* subdir because it
only looked for *-report.md files. Add fallback to detect webm files.
2026-03-21 00:10:16 +00:00
snomiao
9d2233cccb fix: exclude known video names when renaming Playwright recordings
The AFTER step was renaming qa-before-session.webm instead of the new
recording. Filter out already-named files before picking the latest.
2026-03-21 00:00:28 +00:00
snomiao
5bb97d1eae fix: dismiss dropdown overlay before clicking Next in QA login flow
The user dropdown shows "No available options" in CI, and the overlay
blocks the Next button. Dismiss with Escape before attempting click.
2026-03-20 23:51:52 +00:00
snomiao
363678f358 fix: install main branch deps before building, then reinstall PR deps
Main branch imports @vueuse/router which isn't in PR's node_modules.
Need both: main deps for building main, PR deps for running QA scripts.
2026-03-20 23:43:58 +00:00
snomiao
4882960deb fix: reinstall PR deps after main branch build to restore @google/generative-ai
The main branch build step was running pnpm install with main's lockfile,
which removed @google/generative-ai from node_modules. Move the reinstall
to after restoring PR files so the QA recording script can find its deps.
2026-03-20 23:39:02 +00:00
snomiao
dd6cb9e0eb fix: temporarily disable concurrency group to unstick QA runs 2026-03-20 23:32:04 +00:00
snomiao
eda7f22fa8 fix: restore PR files with git checkout HEAD instead of git checkout -
git checkout - uses @{-1} which requires a previous branch switch.
Since we use 'git checkout origin/main -- .' (file checkout, not branch
switch), there is no @{-1} ref. Use HEAD to restore from current branch.

Also restore proper concurrency group.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:26:59 +00:00
snomiao
4f80c88c36 fix: use unique concurrency group to unstick QA runs 2026-03-20 23:20:54 +00:00
snomiao
4b6033f5b9 ci: trigger QA run 2026-03-20 22:55:39 +00:00
snomiao
ee876b470e feat: integrate comfy-qa skill test plan into QA recording pipeline
Pass the comprehensive test plan from .claude/skills/comfy-qa/SKILL.md
to Gemini when generating test steps. This gives Gemini knowledge of all
12 QA categories (canvas, menus, sidebar, settings, etc.) so it picks
the most relevant tests for each PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:22:49 +00:00
snomiao
c94f808f2c fix: use vite build directly to skip nx typecheck dependency
nx build runs typecheck as a prerequisite (via @nx/vite/plugin config).
Use vite build directly for the main branch comparison build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:02:50 +00:00
snomiao
9e2292410a fix: skip typecheck when building main branch for QA comparison
Main branch may have transient TS errors when built with the PR
branch's lockfile. Since we only need the dist for visual comparison,
run nx build directly instead of pnpm build (which includes typecheck).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:48:38 +00:00
snomiao
7ab13f753c refactor: replace Codex with direct Playwright recording in QA pipeline
Replace the unreliable codex exec approach with a Playwright script
(qa-record.ts) that uses Gemini to generate targeted test steps from
the PR diff, then executes them deterministically via Playwright's API.

Key changes:
- New scripts/qa-record.ts: Gemini generates JSON test actions, Playwright
  executes them with reliable helper functions (menu nav, dialog fill, etc.)
- Remove codex CLI and playwright-cli dependencies
- Remove 150+ lines of prompt templates from pr-qa.yaml
- Firefox headless with video recording (same approach proven locally)
- Fallback steps if Gemini fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:43:03 +00:00
snomiao
9615c0168a fix: resolve merge conflict in pr-report.yaml 2026-03-20 20:16:32 +00:00
snomiao
cf84d68e8c feat: auto-generate regression tests from QA reports
- Tighten BEFORE prompt to 15s snapshot (show old state only)
- Add qa-generate-test.ts: Gemini-powered Playwright test generator
- New workflow step: generate .spec.ts and push to {branch}-add-qa-test
- Tests assert UIUX behavior (tab names, dirty state, visibility)
2026-03-20 20:15:25 +00:00
snomiao
050fad59a2 feat: before/after video comparison for QA pipeline
- Build both main (dist-before/) and PR (dist/) frontends in focused mode
- Run QA twice: BEFORE on main branch frontend, AFTER on PR branch
- Send both videos to Gemini in one request for comparative analysis
- Side-by-side dashboard layout with Before (main) / After (PR) panels
- Comparative prompt evaluates whether before confirms old behavior
  and after proves the fix works
- Falls back to single-video mode when no before video available
2026-03-20 16:46:51 +00:00
snomiao
7350353959 fix: make QA videos seekable with faststart and frequent keyframes
moov atom was at end of file (8.6MB offset) — browser had to download
the entire video before seeking. Keyframes were only every 10 seconds.

Add -movflags +faststart (moov before mdat) and -g 60 (keyframe every
2.4s at 25fps) to ffmpeg conversion.
2026-03-20 16:30:04 +00:00
snomiao
0251607c41 fix: issue cards instead of dense table, rename to comfy-qa.pages.dev
- Replace 6-column confirmed issues table with vertical card blocks
  using colored severity/timestamp/confidence badges
- Rename Cloudflare Pages project from comfyui-qa-videos to comfy-qa
2026-03-20 16:17:03 +00:00
snomiao
082c647454 fix: seekable video, hide empty cards, PR-aware video review
- Remove autoplay/loop so video timeline is seekable
- Skip card generation for platforms without recordings
- Add --pr-context flag to qa-video-review.ts so Gemini evaluates
  against PR purpose instead of just describing what happened
- Workflow now builds pr-context.txt from PR title/body/diff
2026-03-20 10:47:31 +00:00
snomiao
2b32994541 feat: redesign QA dashboard with modern frontend design
OKLCH color tokens, liquid glass card surfaces, Inter + JetBrains Mono
typography, grain texture overlay, staggered fade-up animations, pill
action buttons with SVG icons, and improved report table styling.
2026-03-20 08:26:09 +00:00
snomiao
d1e3e747f3 fix: make settings pre-seed non-fatal and try both API endpoints
The /api/settings endpoint returned 4xx in CI. Try both /api/settings
and /settings endpoints, and don't fail the job if neither works.
2026-03-19 16:07:14 +00:00
snomiao
df10e3246b fix: pre-seed Comfy.TutorialCompleted to skip template gallery in QA
The Codex agent was spending 35s browsing the "Getting Started" template
gallery instead of testing the PR's changes. Pre-seeding this setting
via the ComfyUI API ensures the agent lands directly in the graph editor.
2026-03-19 15:57:40 +00:00
snomiao
224c845b6c fix: tighten focused QA prompt to only test PR-specific behavior
The Codex agent was spending time on login flow, template browsing,
and general smoke testing instead of testing the PR's actual changes.

Changes:
- Add 30-second time budget for video recording
- Move video-start AFTER login and editor verification
- Explicitly prohibit template browsing and sidebar exploration
- Reduce test steps to 3-6 targeted actions
- Restructure prompt with clear Instructions/Rules sections
2026-03-19 14:34:01 +00:00
snomiao
d4f22467c0 fix: render markdown in QA reports with marked.js
Replace crude sed-based markdown conversion with client-side
rendering via marked.js CDN. Adds proper table, list, and
code styling for the report section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:11:10 +00:00
snomiao
b764c39492 fix: run report job on workflow_dispatch events
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:08:46 +00:00
snomiao
8b2db18a1b refactor: replace GPT frame extraction with Gemini native video analysis
Replace the OpenAI GPT-based frame extraction approach (ffmpeg + screenshots)
with Gemini 2.5 Flash's native video understanding. This eliminates false
positives from frame-based analysis (e.g. "black screen = critical bug" during
page transitions) and produces dramatically better QA reviews.

Changes:
- Remove ffmpeg frame extraction, ffprobe duration detection, and all related
  logic (~365 lines removed)
- Add @google/generative-ai SDK for native video/mp4 upload to Gemini
- Update CLI: remove --max-frames, --min-interval-seconds, --keep-frames flags
- Update env: OPENAI_API_KEY → GEMINI_API_KEY
- Update workflow: swap API key secret and model in pr-qa.yaml
- Update report: replace "Frames analyzed" with "Video size"
- Add note in prompt that brief black frames during transitions are normal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:33:07 +00:00
snomiao
0b3f6c9bdc fix: use fill+click for quick login before video recording
playwright-cli doesn't support 'evaluate' command. Instead, instruct
Codex to quickly fill the username input and click Next on user-select
page BEFORE starting video recording, so the video only shows actual
QA testing.
2026-03-18 23:21:41 +00:00
snomiao
2f88a07c75 fix: use evaluate to set localStorage before video recording
storageState config doesn't work with playwright-cli. Instead, use
evaluate to set Comfy.userId/userName after opening the page, then
navigate back. This skips user-select before video-start so the
recording only shows actual QA testing.
2026-03-18 23:08:05 +00:00
snomiao
ac7158f25e fix: pre-seed localStorage to skip user-select in QA runs
Write a Playwright storageState JSON with Comfy.userId/userName pre-set
so the app loads directly to the graph editor. Saves ~40s per QA run
that was wasted on navigating the user-select page.
2026-03-18 22:54:41 +00:00
snomiao
3b4402c2d1 fix: prefer explicit qa-session.webm over corrupt auto-recorded videos
The convert step was using find which picked up a 0-byte file from
playwright's videos/ directory instead of the valid qa-session.webm.
Now prefers qa-session.webm explicitly and skips empty files.
2026-03-18 22:31:54 +00:00
snomiao
8d50345193 fix: improve focused QA prompt to test PR-specific behavior, not random walk 2026-03-18 22:10:17 +00:00
snomiao
b00e58e513 fix: re-add push trigger for sno-skills and sno-qa-* branches 2026-03-18 21:27:36 +00:00
snomiao
f4148d13f1 fix: also install ffprobe for GPT video review frame extraction 2026-03-18 21:13:48 +00:00
snomiao
438af97b3d fix: use sudo for ffmpeg static binary extraction to /usr/local/bin 2026-03-18 20:58:55 +00:00
snomiao
7e756ec673 fix: use static ffmpeg binary instead of apt-get (avoids dpkg lock hang) 2026-03-18 20:33:50 +00:00
snomiao
87a4316125 fix: add ffmpeg install back (not pre-installed on GH runners) 2026-03-18 20:16:33 +00:00
snomiao
52a825f631 fix: normalize flat artifact download into expected subdirectory 2026-03-18 20:07:58 +00:00
snomiao
7bde727c7b fix: pre-install chromium and clarify prompt for codex
Codex was using pnpm dlx instead of the global playwright-cli.
Pre-install chromium in setup step and make prompt explicit about
using the global command directly without pnpm/npx.
2026-03-18 19:39:52 +00:00
snomiao
dea1c2c421 fix: add debug output to video convert step 2026-03-18 19:10:47 +00:00
snomiao
7549ab4807 fix: use danger-full-access sandbox for codex on GH Actions 2026-03-18 18:58:46 +00:00
snomiao
3ca4a144ba fix: use correct codex model name gpt-5.4-mini 2026-03-18 18:50:41 +00:00
snomiao
34c6e15678 feat: switch QA from Claude Code to OpenAI Codex CLI
Replace claude --print with codex exec for cheaper QA runs.
Uses codex-mini-latest model ($1.50/$6 vs Sonnet $3/$15).
Uses existing OPENAI_API_KEY secret (no new secrets needed).
2026-03-18 18:45:05 +00:00
snomiao
799fc9e0bc fix: default to linux-only QA, full 3-OS only via qa-full label
Reduces per-run cost from ~$10-16 to ~$2.50 by defaulting to
Linux-only. Use qa-full label or workflow_dispatch for 3-OS runs.
2026-03-18 18:27:57 +00:00
snomiao
c7844f3c74 fix: use explicit video-start/stop, remove ffmpeg install, use gpt-4.1-mini
- Replace saveVideo config (didn't produce video) with explicit
  playwright-cli video-start/video-stop commands in QA prompt
- Remove apt-get install ffmpeg step (pre-installed on GH runners)
- Switch video review model from gpt-4o to gpt-4.1-mini
2026-03-18 18:18:18 +00:00
snomiao
eec387ea49 fix: use auto video recording and show GPT reports on QA site
- Enable saveVideo in playwright-cli config for real video recording
- Replace screenshot stitching with webm→mp4 conversion
- Move video review step before deploy so reports are included
- Add GPT video review reports inline on the Cloudflare Pages site
- Each video card now has expandable "GPT Video Review" section
2026-03-18 17:20:55 +00:00
snomiao
9cbbc05042 fix: configure playwright-cli outputDir and improve artifact collection
- Set .playwright/cli.config.json with outputDir pointing to screenshots/
- This way bare 'playwright-cli screenshot' auto-saves to the right place
- Create screenshot directory before Claude runs (don't rely on Claude)
- Collect step now searches working directory for stray PNGs
- Simplified prompt: no --filename needed, just 'playwright-cli screenshot'
2026-03-18 16:42:30 +00:00
snomiao
cd491b19d3 fix: stitch screenshots from correct directory and simplify prompt
Screenshots were saved to artifact root but stitch looked in frames/.
Now: prompt tells Claude to save to screenshots/ dir with numbered names,
collect step consolidates PNGs there, stitch step globs from screenshots/.
Removed video-start/video-stop (Claude doesn't use them).
2026-03-18 15:50:34 +00:00
snomiao
11575306cc fix: use playwright-cli video recording and collect default output
- Add playwright-cli config with outputDir and saveVideo
- Use video-start/video-stop instead of relying on screenshot frames
- Add fallback artifact collection from .playwright-cli/ default dir
- Simplify prompts to focus on video recording workflow
2026-03-18 15:14:09 +00:00
snomiao
a7d600242b fix: resolve QA_ARTIFACTS path in prompt so Claude gets the literal path
The escaped \$QA_ARTIFACTS in the heredoc produced literal text
'$QA_ARTIFACTS' in the prompt. Claude's Bash tool didn't reliably
expand this env var, so no screenshots or reports were saved.
Remove the escapes so the heredoc expands the variable to the actual
path (e.g. /home/runner/work/_temp/qa-artifacts).
2026-03-18 14:22:29 +00:00
snomiao
f8bdda585a fix: escape backticks in QA prompt heredoc to prevent command substitution
Backtick-wrapped playwright-cli examples in the unquoted heredoc were
being interpreted as bash command substitution, producing empty prompts.
Replace backtick syntax with plain "Run:" prefixed commands.
2026-03-18 13:48:06 +00:00
snomiao
31675b62cc fix: reorganize QA CI — remove screen recording, merge video-review into report
- Remove all Xvfb/ffmpeg screen recording infrastructure from qa job
  (captured blank display since playwright-cli runs headless)
- Add screenshot instructions to QA prompts: Claude saves sequential
  frames to $QA_ARTIFACTS/frames/ after every interaction
- Stitch screenshots into video via ffmpeg in report job (2fps)
- Merge video-review job into report job (4 jobs → 3 jobs)
- Unified PR comment with video links + video review in <details> collapse
- Clean up stale QA_VIDEO_REVIEW_COMMENT markers from prior runs
2026-03-18 09:51:53 +00:00
GitHub Action
02a16acf0a [automated] Apply ESLint and Oxfmt fixes 2026-03-17 06:31:22 +00:00
snomiao
7417aee87d fix: harden setup-comfyui-server against shell injection
Move extra_server_params input to env var to prevent shell injection
from untrusted input. Replace wait-for-it pip dependency with a
cross-platform curl polling loop.
2026-03-17 06:27:51 +00:00
snomiao
f14252accb feat: add comfy-qa skill and automated QA CI pipeline
Add Claude Code skills and a label-triggered QA workflow:

- .claude/skills/comfy-qa/SKILL.md: 12-category QA test plan using
  playwright-cli for browser automation
- .github/workflows/pr-qa.yaml: CI workflow triggered by qa-changes
  (focused, Linux) or qa-full (3-OS matrix) labels. Records screen via
  ffmpeg, runs Claude CLI with playwright-cli, deploys video gallery to
  Cloudflare Pages, posts PR comment with GIF thumbnails, and runs
  OpenAI vision-based video review
- scripts/qa-video-review.ts: frame extraction + GPT-4o analysis
- scripts/qa-video-review.test.ts: unit tests for video review
- knip.config.ts: resolve knip errors for ingest-types package
2026-03-17 06:25:44 +00:00
867 changed files with 17447 additions and 67988 deletions

View File

@@ -1,118 +0,0 @@
---
name: adr-compliance
description: Checks code changes against Architecture Decision Records, with emphasis on ECS (ADR 0008) and command-pattern (ADR 0003) compliance
severity-default: medium
tools: [Read, Grep, glob]
---
Check that code changes are consistent with the project's Architecture Decision Records in `docs/adr/`.
## Priority 1: ECS and Command-Pattern Compliance (ADR 0008 + ADR 0003)
These are the primary architectural guardrails. Every entity/litegraph change must be checked against them.
### Command Pattern (ADR 0003)
All entity state mutations MUST be expressible as **serializable, idempotent, deterministic commands**. This is required for CRDT sync, undo/redo, cross-environment portability, and gateway backends.
Flag:
- **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside of a store or command. All spatial data flows through `layoutStore` commands.
- **Imperative fire-and-forget mutation** — Any new API that mutates entity state as a side effect rather than producing a serializable command object. Systems should produce command batches, not execute mutations directly.
- **Void-returning mutation APIs** — New entity mutation functions that return `void` instead of a result type (`{ status: 'applied' | 'rejected' | 'no-op' }`). Commands need error/rejection semantics.
- **Auto-incrementing IDs in new entity code** — New entity creation using auto-increment counters without acknowledging the CRDT collision problem. Concurrent environments need globally unique, stable identifiers.
### ECS Architecture (ADR 0008)
The graph domain model is migrating to ECS. New code must not make the migration harder.
Flag:
- **God-object growth** — New methods/properties added to `LGraphNode` (~4k lines), `LGraphCanvas` (~9k lines), `LGraph` (~3k lines), or `Subgraph`. Extract to systems, stores, or composables instead.
- **Mixed data and behavior** — New component-like data structures that contain methods or back-references to parent entities. ECS components are plain data objects.
- **New circular entity dependencies** — New circular imports between `LGraph``Subgraph`, `LGraphNode``LGraphCanvas`, or similar entity classes.
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
### Centralized Registries and ECS-Style Access
All entity data access should move toward centralized query patterns, not instance property access.
Flag:
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
### Extension Ecosystem Impact
Entity API changes affect 40+ custom node repos. Changes to these patterns require an extension migration path.
Flag when changed without migration guidance:
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
- `onConnectInput` / `onConnectOutput` validation hooks
- `onWidgetChanged` handlers
- `node.widgets.find(w => w.name === ...)` patterns
- `node.serialize` overrides
- `graph._version++` direct mutation
- `getNodeById` usage patterns
## Priority 2: General ADR Compliance
For all other ADRs, iterate through each file in `docs/adr/` and extract the core lesson. Ensure changed code does not contradict accepted ADRs. Flag contradictions with proposed ADRs as directional guidance.
### How to Apply
1. Read `docs/adr/README.md` to get the full ADR index
2. For each ADR, read the Decision and Consequences sections
3. Check the diff against each ADR's constraints
4. Only flag ACTUAL violations in changed code, not pre-existing patterns
### Skip List
These ADRs can be skipped for most reviews (they cover completed or narrow-scope decisions):
- **ADR 0004** (Rejected — Fork PrimeVue) — only relevant if someone proposes forking PrimeVue again
## How to Check
1. Identify changed files in the entity/litegraph layer: `src/lib/litegraph/`, `src/ecs/`, `src/platform/`, entity-related stores
2. For Priority 1 patterns, use targeted searches:
```
# Direct position mutation
Grep: pattern="\.pos\s*=" path="src/lib/litegraph"
Grep: pattern="\.size\s*=" path="src/lib/litegraph"
# God object growth (new methods)
Grep: pattern="(class LGraphNode|class LGraphCanvas|class LGraph\b)" path="src/lib/litegraph"
# Version mutation
Grep: pattern="_version\+\+" path="src/lib/litegraph"
# Extension callback changes
Grep: pattern="on(ConnectionsChange|Removed|Added|Configure|ConnectInput|ConnectOutput|WidgetChanged)" path="src/lib/litegraph"
```
3. For Priority 2, read `docs/adr/` files and check for contradictions
## Severity Guidelines
| Issue | Severity |
| -------------------------------------------------------- | -------- |
| Imperative mutation API without command-pattern wrapper | high |
| New god-object method on LGraphNode/LGraphCanvas/LGraph | high |
| Breaking extension callback without migration path | high |
| New circular entity dependency | high |
| Direct spatial mutation bypassing command pattern | medium |
| Mixed data/behavior in component-like structures | medium |
| New OOP inheritance pattern for entities | medium |
| Contradicts accepted ADR direction | medium |
| Contradicts proposed ADR direction without justification | low |
## Rules
- Only flag ACTUAL violations in changed code, not pre-existing patterns
- If a change explicitly acknowledges an ADR tradeoff in comments or PR description, lower severity
- Proposed ADRs carry less weight than accepted ones — flag as directional guidance
- Reference the specific ADR number in every finding

View File

@@ -1,74 +0,0 @@
---
name: playwright-e2e
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
severity-default: medium
tools: [Read, Grep]
---
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
Reference docs (read if you need full context):
- `browser_tests/README.md` — setup, patterns, screenshot workflow
- `browser_tests/AGENTS.md` — directory structure, fixture overview
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
## Checks
### Flakiness Risks (Major)
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
3. **Keyboard actions without prior focus**`page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
7. **Double-click without `{ delay }` option**`dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
### Fixture & API Misuse (Medium)
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
- `comfyPage.command.executeCommand()` for menu/command actions
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
- `comfyPage.canvasOps.resetView()` for view reset
- `comfyPage.settings.setSetting()` for settings
- Component page objects in `browser_tests/fixtures/components/`
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
### Convention Violations (Minor)
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
15. **Debug helpers left in committed code**`debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
### Test Design (Nitpick)
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
## Rules
- Only review `.spec.ts` files and supporting code in `browser_tests/`
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
- Existing tests that predate conventions are acceptable to modify but not required to fix

View File

@@ -1,94 +0,0 @@
# ADR Compliance Audit
Audit the current changes (or a specified PR) for compliance with Architecture Decision Records.
## Step 1: Gather the Diff
- If a PR number is provided, run: `gh pr diff $PR_NUMBER`
- Otherwise, run: `git diff origin/main...HEAD` (or `git diff --cached` for staged changes)
## Step 2: Priority 1 — ECS and Command-Pattern Compliance
Read these documents for context:
```
docs/adr/0003-crdt-based-layout-system.md
docs/adr/0008-entity-component-system.md
docs/architecture/ecs-target-architecture.md
docs/architecture/ecs-migration-plan.md
docs/architecture/appendix-critical-analysis.md
```
### Check A: Command Pattern (ADR 0003)
Every entity state mutation must be a **serializable, idempotent, deterministic command** — replayable, undoable, transmittable over CRDT.
Flag:
1. **Direct spatial mutation**`node.pos = ...`, `node.size = ...`, `group.pos = ...` outside a store/command
2. **Imperative fire-and-forget APIs** — Functions that mutate entity state as side effects rather than producing serializable command objects. Systems should produce command batches, not execute mutations directly.
3. **Void-returning mutation APIs** — Entity mutations returning `void` instead of `{ status: 'applied' | 'rejected' | 'no-op' }`
4. **Auto-increment IDs** — New entity creation via counters without addressing CRDT collision. Concurrent environments need globally unique identifiers.
5. **Missing transaction semantics** — Multi-entity operations without atomic grouping (e.g., node removal = 10+ deletes with no rollback on failure)
### Check B: ECS Architecture (ADR 0008)
Flag:
1. **God-object growth** — New methods/properties on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
2. **Mixed data/behavior** — Component-like structures with methods or back-references
3. **OOP instance patterns** — New `node.someProperty` or `node.someMethod()` for data that should be a World component
4. **OOP inheritance** — New entity subclasses instead of component composition
5. **Circular entity deps** — New `LGraph``Subgraph`, `LGraphNode``LGraphCanvas` circular imports
6. **Direct `_version++`** — Mutating private version counter instead of through public API
### Check C: Extension Ecosystem Impact
If any of these patterns are changed, flag and require migration guidance:
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
- `onConnectInput` / `onConnectOutput` validation hooks
- `onWidgetChanged` handlers
- `node.widgets.find(w => w.name === ...)` access patterns
- `node.serialize` overrides
- `graph._version++` direct mutation
Reference: 40+ custom node repos depend on these (rgthree-comfy, ComfyUI-Impact-Pack, cg-use-everywhere, etc.)
## Step 3: Priority 2 — General ADR Compliance
1. Read `docs/adr/README.md` for the full ADR index
2. For each ADR (except skip list), read the Decision section
3. Check the diff for contradictions
4. Only flag ACTUAL violations in changed code
**Skip list**: ADR 0004 (Rejected — Fork PrimeVue)
## Step 4: Generate Report
```
## ADR Compliance Audit Report
### Summary
- Files audited: N
- Priority 1 findings: N (command-pattern: N, ECS: N, ecosystem: N)
- Priority 2 findings: N
### Priority 1: Command Pattern & ECS
(List each with ADR reference, file, line, description)
### Priority 1: Extension Ecosystem Impact
(List each changed callback/API with affected custom node repos)
### Priority 2: General ADR Compliance
(List each with ADR reference, file, line, description)
### Compliant Patterns
(Note changes that positively align with ADR direction)
```
## Severity
- **Must fix**: Contradicts accepted ADR, or introduces imperative mutation API without command-pattern wrapper, or breaks extension callback without migration path
- **Should discuss**: Contradicts proposed ADR direction — either align or propose ADR amendment
- **Note**: Surfaces open architectural question not yet addressed by ADRs

View File

@@ -1,84 +0,0 @@
---
name: adding-deprecation-warnings
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
---
# Adding Deprecation Warnings
Adds backward-compatible deprecation warnings for renamed or removed
properties using the `defineDeprecatedProperty` helper in
`src/lib/litegraph/src/utils/feedback.ts`.
## When to Use
- A property or API has been renamed and custom nodes still use the old name
- A property is being removed but needs a grace period
- Backward compatibility must be preserved while nudging adoption
## Steps
### 1. Search the Custom Node Ecosystem
Before implementing, assess impact by searching for usage of the
deprecated property across ComfyUI custom nodes:
```text
Use the comfy_codesearch tool to search for the old property name.
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
```
Document the usage patterns found (property access, truthiness checks,
caching to local vars, style mutation, etc.) — these all must continue
working.
### 2. Apply the Deprecation
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
```typescript
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
/** @deprecated Use {@link obj.newProp} instead. */
defineDeprecatedProperty(
obj,
'oldProp',
'newProp',
'obj.oldProp is deprecated. Use obj.newProp instead.'
)
```
### 3. Checklist
- [ ] Ecosystem search completed — all usage patterns are compatible
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
- [ ] Warning message names both old and new property clearly
- [ ] `pnpm typecheck` passes
- [ ] `pnpm lint` passes
### 4. PR Comment
Add a PR comment summarizing the ecosystem search results: which repos
use the deprecated property, what access patterns were found, and
confirmation that all patterns are compatible with the ODP getter/setter.
## How `defineDeprecatedProperty` Works
- Creates an `Object.defineProperty` getter/setter on the target object
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
- Both log via `warnDeprecated`, which deduplicates (once per unique
message per session via a `Set`)
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
/ `JSON.stringify`
- `configurable: true` allows further redefinition if needed
## Edge Cases
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
once then the cached ref is used directly
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
works, getter returns the real object
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
once per session regardless of call count

View File

@@ -18,20 +18,12 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## System Context
| Item | Value |
| -------------- | --------------------------------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
| Tracking dir | `~/temp/backport-session/` |
## CI Safety Rules
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
| Item | Value |
| -------------- | ------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
| Tracking dir | `~/temp/backport-session/` |
## Branch Scope Rules
@@ -116,15 +108,11 @@ git fetch origin TARGET_BRANCH
# Quick smoke check: does the branch build?
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
### Never Admin-Merge Without CI
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
## Continuous Backporting Recommendation

View File

@@ -19,44 +19,23 @@ done
# Wait 3 minutes for automation
sleep 180
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
# Check which got auto-PRs
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
```
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
## Step 2: Wait for CI & Merge Clean Auto-PRs
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
## Step 2: Review & Merge Clean Auto-PRs
```bash
# Wait for CI to complete (~45 minutes for full suite)
sleep 2700
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
# For PRs still open, check CI status
for pr in $STILL_OPEN_PRS; do
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
if [ "$CI_FAILED" != "0" ]; then
# CI failed — collect details for triage
echo "PR #$pr — CI FAILED:"
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
elif [ "$CI_PENDING" != "0" ]; then
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
else
# All checks passed but didn't auto-merge (race condition or label issue)
gh pr merge $pr --squash --admin
sleep 3
fi
for pr in $AUTO_PRS; do
# Check size
gh pr view $pr --json title,additions,deletions,changedFiles \
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
# Admin merge
gh pr merge $pr --squash --admin
sleep 3
done
```
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
## Step 3: Manual Worktree for Conflicts
```bash
@@ -84,13 +63,6 @@ for PR in ${CONFLICT_PRS[@]}; do
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
--body "Backport of #$PR..." | grep -oP '\d+$')
# Wait for CI before merging — NEVER admin-merge without CI passing
echo "Waiting for CI on PR #$NEW_PR..."
gh pr checks $NEW_PR --watch --fail-fast || {
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
continue
}
gh pr merge $NEW_PR --squash --admin
sleep 3
done
@@ -110,7 +82,7 @@ After completing all PRs in a wave for a target branch:
git fetch origin TARGET_BRANCH
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
@@ -160,8 +132,7 @@ git rebase origin/TARGET_BRANCH
# Resolve new conflicts
git push --force origin backport-$PR-to-TARGET
sleep 20 # Wait for GitHub to recompute merge state
# Wait for CI after rebase before merging
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
gh pr merge $PR --squash --admin
```
## Lessons Learned
@@ -175,31 +146,5 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
8. **Always validate JSON** after resolving locale file conflicts
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
12. **Never admin-merge without CI**`--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
## CI Failure Triage
When CI fails on a backport PR, present failures to the user using this template:
```markdown
### PR #XXXX — CI Failed
- **Failing check:** test / lint / typecheck
- **Error:** (summary of the failure message)
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
```
Common failure categories:
| Category | Example | Resolution |
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
| Type error | Interface changed on main but not branch | May need manual adaptation |
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.

View File

@@ -5,9 +5,9 @@
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | CI Status | Status | Backport PR | Notes |
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
@@ -19,7 +19,6 @@ Track verification results per wave:
- PRs merged: #A, #B, #C
- Typecheck: ✅ Pass / ❌ Fail
- Unit tests: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
@@ -42,11 +41,6 @@ Track verification results per wave:
| PR# | Branch | Conflict Type | Resolution Summary |
## CI Failure Report
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
| --- | ------ | ------------- | ------------- | ----- | ---------- |
## Automation Performance
| Metric | Value |

View File

@@ -0,0 +1,361 @@
---
name: comfy-qa
description: 'Comprehensive QA of ComfyUI frontend. Navigates all routes, tests all interactive features using playwright-cli, generates a report, and submits a draft PR. Works in CI and local environments, cross-platform.'
---
# ComfyUI Frontend QA Skill
Perform comprehensive quality assurance of the ComfyUI frontend application by navigating all routes, clicking interactive elements, and testing features. Generate a structured report and submit it as a draft PR.
## Prerequisites
- Node.js 22+
- `pnpm` package manager
- `gh` CLI (authenticated)
- `playwright-cli` (browser automation): `npm install -g @playwright/cli@latest`
## Step 1: Environment Detection & Setup
Detect the runtime environment and ensure the app is accessible.
### CI Environment
If `CI=true` is set:
1. The ComfyUI backend is pre-configured in the CI container (`ghcr.io/comfy-org/comfyui-ci-container`)
2. Frontend dist is already built and served by the backend
3. Server runs at `http://127.0.0.1:8188`
4. Skip user prompts — run fully automated
### Local Environment
If `CI` is not set:
1. **Ask the user**: "Is a ComfyUI server already running? If so, what URL? (default: http://127.0.0.1:8188)"
- If yes: use the provided URL
- If no: offer to start one:
```bash
# Option A: Use existing ComfyUI installation
# Ask for the path to ComfyUI, then:
cd <comfyui_path>
python main.py --cpu --multi-user --front-end-root <frontend_dist_path> &
# Option B: Build frontend and use preview server (no backend features)
pnpm build && pnpm preview &
```
2. Wait for server readiness by polling the URL (retry with 2s intervals, 60s timeout)
### Browser Automation Setup
Use **playwright-cli** for browser interaction via Bash commands:
```bash
playwright-cli open http://127.0.0.1:8188 # open browser and navigate
playwright-cli snapshot # capture snapshot with element refs
playwright-cli click e1 # click by element ref from snapshot
playwright-cli press Tab # keyboard shortcuts
playwright-cli screenshot --filename=f.png # save screenshot
```
playwright-cli is headless by default (CI-friendly). Each command outputs the current page snapshot with element references (`e1`, `e2`, …) that you use for subsequent `click`, `fill`, `hover` commands. Always run `snapshot` before interacting to get fresh refs.
For local dev servers behind proxies, adjust the URL accordingly (e.g., `https://[port].stukivx.xyz` pattern if configured).
## Step 2: QA Test Plan
Navigate to the application URL and systematically test each area below. For each test, record:
- **Status**: pass / fail / skip (with reason)
- **Notes**: any issues, unexpected behavior, or visual glitches
- **Screenshots**: take screenshots of failures or notable states
### 2.1 Application Load & Routes
| Test | Steps |
| ----------------- | ------------------------------------------------------------ |
| Root route loads | Navigate to `/` — GraphView should render with canvas |
| User select route | Navigate to `/user-select` — user selection UI should appear |
| Default redirect | If multi-user mode, `/` redirects to `/user-select` first |
| 404 handling | Navigate to `/nonexistent` — should handle gracefully |
### 2.2 Canvas & Graph View
| Test | Steps |
| ------------------------- | -------------------------------------------------------------- |
| Canvas renders | The LiteGraph canvas is visible and interactive |
| Pan canvas | Click and drag on empty canvas area |
| Zoom in/out | Use scroll wheel or Alt+=/Alt+- |
| Fit view | Press `.` key — canvas fits to content |
| Add node via double-click | Double-click canvas to open search, type "KSampler", select it |
| Add node via search | Open search box, find and add a node |
| Delete node | Select a node, press Delete key |
| Connect nodes | Drag from output slot to input slot |
| Disconnect nodes | Right-click a link and remove, or drag from connected slot |
| Multi-select | Shift+click or drag-select multiple nodes |
| Copy/Paste | Select nodes, Ctrl+C then Ctrl+V |
| Undo/Redo | Make changes, Ctrl+Z to undo, Ctrl+Y to redo |
| Node context menu | Right-click a node — menu appears with all expected options |
| Canvas context menu | Right-click empty canvas — menu appears |
### 2.3 Node Operations
| Test | Steps |
| ------------------- | ---------------------------------------------------------- |
| Bypass node | Select node, Ctrl+B — node shows bypass state |
| Mute node | Select node, Ctrl+M — node shows muted state |
| Collapse node | Select node, Alt+C — node collapses |
| Pin node | Select node, press P — node becomes pinned |
| Rename node | Double-click node title — edit mode activates |
| Node color | Right-click > Color — color picker works |
| Group nodes | Select multiple nodes, Ctrl+G — group created |
| Ungroup | Right-click group > Ungroup |
| Widget interactions | Toggle checkboxes, adjust sliders, type in text fields |
| Combo widget | Click dropdown widgets — options appear and are selectable |
### 2.4 Sidebar Tabs
| Test | Steps |
| ---------------------- | ------------------------------------------------------ |
| Workflows tab | Press W — workflows sidebar opens with saved workflows |
| Node Library tab | Press N — node library opens with categories |
| Model Library tab | Press M — model library opens |
| Assets tab | Press A — assets browser opens |
| Tab toggle | Press same key again — sidebar closes |
| Search in sidebar | Type in search box — results filter |
| Drag node from library | Drag a node from library onto canvas |
### 2.5 Topbar & Workflow Tabs
| Test | Steps |
| -------------------- | ------------------------------------------------------ |
| Workflow tab display | Current workflow name shown in tab bar |
| New workflow | Ctrl+N — new blank workflow created |
| Rename workflow | Double-click workflow tab |
| Tab context menu | Right-click workflow tab — menu with Close/Rename/etc. |
| Multiple tabs | Open multiple workflows, switch between them |
| Queue button | Click Queue/Run button — prompt queues |
| Batch count | Click batch count editor, change value |
| Menu hamburger | Click hamburger menu — options appear |
### 2.6 Settings Dialog
| Test | Steps |
| ---------------- | ---------------------------------------------------- |
| Open settings | Press Ctrl+, or click settings button |
| Settings tabs | Navigate through all setting categories |
| Change a setting | Toggle a boolean setting — it persists after closing |
| Search settings | Type in settings search box — results filter |
| Keybindings tab | Navigate to keybindings panel |
| About tab | Navigate to about panel — version info shown |
| Close settings | Press Escape or click close button |
### 2.7 Bottom Panel
| Test | Steps |
| ------------------- | -------------------------------------- |
| Toggle panel | Press Ctrl+` — bottom panel opens |
| Logs tab | Logs/terminal tab shows server output |
| Shortcuts tab | Shortcuts reference is displayed |
| Keybindings display | Press Ctrl+Shift+K — keybindings panel |
### 2.8 Execution & Queue
| Test | Steps |
| -------------- | ----------------------------------------------------- |
| Queue prompt | Load default workflow, click Queue — execution starts |
| Queue progress | Progress indicator shows during execution |
| Interrupt | Press Ctrl+Alt+Enter during execution — interrupts |
| Job history | Open job history sidebar — past executions listed |
| Clear history | Clear execution history via menu |
### 2.9 Workflow File Operations
| Test | Steps |
| --------------- | ------------------------------------------------- |
| Save workflow | Ctrl+S — workflow saves (check for prompt if new) |
| Open workflow | Ctrl+O — file picker or workflow browser opens |
| Export JSON | Menu > Export — workflow JSON downloads |
| Import workflow | Drag a .json workflow file onto canvas |
| Load default | Menu > Load Default — default workflow loads |
| Clear workflow | Menu > Clear — canvas clears (after confirmation) |
### 2.10 Advanced Features
| Test | Steps |
| --------------- | ------------------------------------------------- |
| Minimap | Alt+M — minimap toggle |
| Focus mode | Toggle focus mode |
| Canvas lock | Press H to lock, V to unlock |
| Link visibility | Ctrl+Shift+L — toggle links |
| Subgraph | Select nodes > Ctrl+Shift+E — convert to subgraph |
### 2.11 Error Handling
| Test | Steps |
| --------------------- | -------------------------------------------- |
| Missing nodes dialog | Load workflow with non-existent node types |
| Missing models dialog | Trigger missing model warning |
| Network error | Disconnect backend, verify graceful handling |
| Invalid workflow | Try loading malformed JSON |
### 2.12 Responsive & Accessibility
| Test | Steps |
| ------------------- | ------------------------------------- |
| Window resize | Resize browser window — layout adapts |
| Keyboard navigation | Tab through interactive elements |
| Sidebar resize | Drag sidebar edge to resize |
## Step 3: Generate Report
After completing all tests, generate a markdown report file.
### Report Location
```
docs/qa/YYYY-MM-DD-NNN-report.md
```
Where:
- `YYYY-MM-DD` is today's date
- `NNN` is a zero-padded increment index (001, 002, etc.)
To determine the increment, check existing files:
```bash
ls docs/qa/ | grep "$(date +%Y-%m-%d)" | wc -l
```
### Report Template
```markdown
# QA Report: ComfyUI Frontend
**Date**: YYYY-MM-DD
**Environment**: CI / Local (OS, Browser)
**Frontend Version**: (git sha or version)
**Agent**: Claude / Codex / Other
**Server URL**: http://...
## Summary
| Category | Pass | Fail | Skip | Total |
| --------------- | ---- | ---- | ---- | ----- |
| Routes & Load | | | | |
| Canvas | | | | |
| Node Operations | | | | |
| Sidebar | | | | |
| Topbar | | | | |
| Settings | | | | |
| Bottom Panel | | | | |
| Execution | | | | |
| File Operations | | | | |
| Advanced | | | | |
| Error Handling | | | | |
| Responsive | | | | |
| **Total** | | | | |
## Results
### Routes & Load
- [x] Root route loads — pass
- [ ] ...
### Canvas & Graph View
- [x] Canvas renders — pass
- [ ] ...
(repeat for each category)
## Issues Found
### Issue 1: [Title]
- **Severity**: critical / major / minor / cosmetic
- **Steps to reproduce**: ...
- **Expected**: ...
- **Actual**: ...
- **Screenshot**: (if available)
## Notes
Any additional observations, performance notes, or suggestions.
```
## Step 4: Commit and Push Report
### In CI (when `CI=true`)
Save the report directly to `$QA_ARTIFACTS` (the CI workflow uploads this as
an artifact and posts results as a PR comment). Do **not** commit, push, or
create a new PR.
### Local / interactive use
When running locally, create a draft PR after committing:
```bash
# Ensure on a feature branch
BRANCH_NAME="qa/$(date +%Y-%m-%d)-$(git rev-parse --short HEAD)"
git checkout -b "$BRANCH_NAME" 2>/dev/null || git checkout "$BRANCH_NAME"
git add docs/qa/
git commit -m "docs: add QA report $(date +%Y-%m-%d)
Automated QA report covering all frontend routes and features."
git push -u origin "$BRANCH_NAME"
# Create draft PR assigned to comfy-pr-bot
gh pr create \
--draft \
--title "QA Report: $(date +%Y-%m-%d)" \
--body "## QA Report
Automated frontend QA run covering all routes and interactive features.
See \`docs/qa/\` for the full report.
/cc @comfy-pr-bot" \
--assignee comfy-pr-bot
```
## Execution Notes
### Cross-Platform Considerations
- **Windows**: Use `pwsh` or `cmd` equivalents for shell commands. `gh` CLI works on all platforms.
- **macOS**: Keyboard shortcuts use Cmd instead of Ctrl in the actual app, but Playwright sends OS-appropriate keys.
- **Linux**: Primary CI platform. Screenshot baselines are Linux-only.
### Agent Compatibility
This skill uses **playwright-cli** (`@playwright/cli`) — a token-efficient CLI designed for coding agents. Install it once with `npm install -g @playwright/cli@latest`, then use `Bash` to run commands.
The key operations and their playwright-cli equivalents:
| Action | Command |
| ---------------- | ---------------------------------------- |
| Navigate to URL | `playwright-cli goto <url>` |
| Get element refs | `playwright-cli snapshot` |
| Click element | `playwright-cli click <ref>` |
| Type text | `playwright-cli fill <ref> <text>` |
| Press shortcut | `playwright-cli press <key>` |
| Take screenshot | `playwright-cli screenshot --filename=f` |
| Hover element | `playwright-cli hover <ref>` |
| Select dropdown | `playwright-cli select <ref> <value>` |
Snapshots return element references (`e1`, `e2`, …). Always run `snapshot` after navigation or major interactions to refresh refs before acting.
### Tips for Reliable QA
1. **Wait for page stability** before interacting — check that elements are visible and enabled
2. **Take a snapshot after each major navigation** to verify state
3. **Don't use fixed timeouts** — poll for expected conditions
4. **Record the full page snapshot** at the start for baseline comparison
5. **If a test fails**, document it and continue — don't abort the entire QA run
6. **Group related tests** — complete one category before moving to the next

View File

@@ -1,99 +0,0 @@
---
name: contain-audit
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
---
# CSS Containment Audit
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
## What It Does
1. Loads a large workflow (245 nodes) in a real browser
2. Walks the DOM tree and scores every element as a containment candidate
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
5. Takes before/after screenshots to detect visual breakage
6. Generates a ranked report with actionable recommendations
## When to Use
- After adding new Vue components to the node rendering pipeline
- When investigating rendering performance on large workflows
- Before and after refactoring node DOM structure
- As part of periodic performance audits
## How to Run
```bash
# Start the dev server first
pnpm dev &
# Run the audit (uses the @audit tag, not included in normal CI runs)
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
# View the HTML report
pnpm exec playwright show-report
```
## How to Read Results
The audit outputs a table to the console:
```text
CSS Containment Audit Results
=======================================================
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
2 | .node-body | 12 | 48 | -8% | -3% | OK
3 | .node-header | 4 | 16 | +1% | 0% | OK
```
- **Subtree**: Number of descendant elements (higher = more to skip)
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
## Candidate Scoring
An element is a good containment candidate when:
1. **Large subtree** -- many descendants that the browser can skip recalculating
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
3. **No existing containment** -- `contain` is not already applied
4. **Not a leaf** -- has at least a few child elements
Elements that should NOT get containment:
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
- Elements whose height is determined by content and affects sibling layout
- Very small subtrees (overhead of containment context outweighs benefit)
## Limitations
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
- Performance measurements have natural variance; run multiple times for confidence
- Only tests idle and pan scenarios; widget interactions may differ
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
## Example PR
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
## Reference
| Resource | Path |
| ----------------- | ------------------------------------------------------- |
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf tests | `browser_tests/tests/performance.spec.ts` |
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |

View File

@@ -1,82 +0,0 @@
---
name: layer-audit
description: 'Detect violations of the layered architecture import rules (base -> platform -> workbench -> renderer). Runs ESLint with the import-x/no-restricted-paths rule and generates a grouped report.'
---
# Layer Architecture Audit
Finds imports that violate the layered architecture boundary rules enforced by `import-x/no-restricted-paths` in `eslint.config.ts`.
## Layer Hierarchy (bottom to top)
```
renderer (top -- can import from all lower layers)
^
workbench
^
platform
^
base (bottom -- cannot import from any upper layer)
```
Each layer may only import from layers below it.
## How to Run
```bash
# Run ESLint filtering for just the layer boundary rule violations
pnpm lint 2>&1 | grep 'import-x/no-restricted-paths' -B1 | head -200
```
To get a full structured report, run:
```bash
# Collect all violations from base/, platform/, workbench/ layers
pnpm eslint src/base/ src/platform/ src/workbench/ --no-error-on-unmatched-pattern --rule '{"import-x/no-restricted-paths": "warn"}' --format compact 2>&1 | grep 'no-restricted-paths' | sort
```
## How to Read Results
Each violation line shows:
- The **file** containing the bad import
- The **import path** crossing the boundary
- The **message** identifying which layer pair is violated
### Grouping by Layer Pair
After collecting violations, group them by the layer pair pattern:
| Layer pair | Meaning |
| --------------------- | ----------------------------------- |
| base -> platform | base/ importing from platform/ |
| base -> workbench | base/ importing from workbench/ |
| base -> renderer | base/ importing from renderer/ |
| platform -> workbench | platform/ importing from workbench/ |
| platform -> renderer | platform/ importing from renderer/ |
| workbench -> renderer | workbench/ importing from renderer/ |
## When to Use
- Before creating a PR that adds imports between `src/base/`, `src/platform/`, `src/workbench/`, or `src/renderer/`
- When auditing the codebase to find and plan migration of existing violations
- After moving files between layers to verify no new violations were introduced
## Fixing Violations
Common strategies to resolve a layer violation:
1. **Move the import target down** -- if the imported module doesn't depend on upper-layer concepts, move it to a lower layer
2. **Introduce an interface** -- define an interface/type in the lower layer and implement it in the upper layer via dependency injection or a registration pattern
3. **Move the importing file up** -- if the file logically belongs in a higher layer, relocate it
4. **Extract shared logic** -- pull the shared functionality into `base/` or a shared utility
## Reference
| Resource | Path |
| ------------------------------- | ------------------ |
| ESLint config (rule definition) | `eslint.config.ts` |
| Base layer | `src/base/` |
| Platform layer | `src/platform/` |
| Workbench layer | `src/workbench/` |
| Renderer layer | `src/renderer/` |

View File

@@ -44,18 +44,15 @@ await expect(node).toHaveClass(BYPASS_CLASS)
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
| Symptom | Common Cause | Typical Fix |
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
| `subtree intercepts pointer events` | Canvas overlay (z-999) | Use `dispatchEvent` on the DOM element to bypass overlay |
| Context menu empty / wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
| `navigateIntoSubgraph` timeout | Node too small in asset | Use node size `[400, 200]` minimum in test asset JSON |
| Symptom | Common Cause | Typical Fix |
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
## Test Tags

View File

@@ -28,21 +28,3 @@ reviews:
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
This check applies ONLY when the PR modifies files under `src/lib/litegraph/`, `src/ecs/`, or files related to graph entities (nodes, links, widgets, slots, reroutes, groups, subgraphs).
If none of those paths appear in the changed files, pass immediately.
When applicable, check for:
1. **Command pattern (ADR 0003)**: Entity state mutations must be serializable, idempotent, deterministic commands — not imperative fire-and-forget side effects. Flag direct spatial mutation (`node.pos =`, `node.size =`, `group.pos =`) outside of a store or command, and any new void-returning mutation API that should produce a command object.
2. **God-object growth (ADR 0008)**: New methods/properties added to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` that add responsibilities rather than extracting/migrating existing ones.
3. **ECS data/behavior separation (ADR 0008)**: Component-like data structures that contain methods or back-references to parent entities. ECS components must be plain data. New OOP instance patterns (`node.someProperty`, `node.someMethod()`) for data that should be a World component.
4. **Extension ecosystem (ADR 0008)**: Changes to extension-facing callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure`, `onConnectInput/Output`, `onWidgetChanged`), `node.widgets` access, `node.serialize` overrides, or `graph._version++` without migration guidance. These affect 40+ custom node repos.
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.

View File

@@ -44,12 +44,17 @@ runs:
python -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
pip install wait-for-it
- name: Start ComfyUI server
if: ${{ inputs.launch_server == 'true' }}
shell: bash
working-directory: ComfyUI
env:
EXTRA_SERVER_PARAMS: ${{ inputs.extra_server_params }}
run: |
python main.py --cpu --multi-user --front-end-root ../dist ${{ inputs.extra_server_params }} &
wait-for-it --service 127.0.0.1:8188 -t 600
python main.py --cpu --multi-user --front-end-root ../dist $EXTRA_SERVER_PARAMS &
for i in $(seq 1 300); do
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready" && exit 0
sleep 2
done
echo "::error::ComfyUI server did not start within 600s" && exit 1

View File

@@ -12,7 +12,9 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -16,7 +16,9 @@ jobs:
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -21,7 +21,9 @@ jobs:
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -20,7 +20,9 @@ jobs:
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -19,7 +19,9 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -20,7 +20,9 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -73,7 +75,9 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -64,7 +64,6 @@ jobs:
mkdir -p temp/perf-meta
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
- name: Upload PR metadata
if: github.event_name == 'pull_request'

View File

@@ -8,10 +8,6 @@ on:
branches:
- main
concurrency:
group: size-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -32,12 +28,11 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
- name: Save PR number & base branch
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6

View File

@@ -33,27 +33,13 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6
with:
name: frontend-dist-cloud
path: dist/
retention-days: 1
# Sharded chromium tests
playwright-tests-chromium-sharded:
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -101,7 +87,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -111,14 +97,14 @@ jobs:
strategy:
fail-fast: false
matrix:
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v7
with:
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
name: frontend-dist
path: dist/
- name: Start ComfyUI server
@@ -158,7 +144,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10

View File

@@ -29,7 +29,9 @@ jobs:
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

148
.github/workflows/pr-perf-report.yaml vendored Normal file
View File

@@ -0,0 +1,148 @@
name: 'PR: Performance Report'
on:
workflow_run:
workflows: ['CI: Performance Report']
types:
- completed
permissions:
contents: read
pull-requests: write
issues: write
actions: read
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Download PR metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: perf-meta
run_id: ${{ github.event.workflow_run.id }}
path: temp/perf-meta/
- name: Resolve and validate PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const artifactPr = Number(fs.readFileSync('temp/perf-meta/number.txt', 'utf8').trim());
const artifactBase = fs.readFileSync('temp/perf-meta/base.txt', 'utf8').trim();
// Resolve PR from trusted workflow context
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.setFailed('Unable to resolve PR from workflow_run context.');
return;
}
if (Number(pr.number) !== artifactPr) {
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
return;
}
const trustedBase = pr.base?.ref;
if (!trustedBase || artifactBase !== trustedBase) {
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
return;
}
core.setOutput('number', String(pr.number));
core.setOutput('base', trustedBase);
- name: Check if results are still current
id: sha-check
uses: actions/github-script@v8
with:
script: |
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const runSha = context.payload.workflow_run.head_sha;
const currentSha = pr.head.sha;
if (runSha !== currentSha) {
core.info(`Skipping stale report: run SHA ${runSha} != current PR SHA ${currentSha}`);
core.setOutput('stale', 'true');
} else {
core.setOutput('stale', 'false');
}
- name: Download PR perf metrics
if: steps.sha-check.outputs.stale != 'true'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: perf-metrics
run_id: ${{ github.event.workflow_run.id }}
path: test-results/
- name: Download baseline perf metrics
if: steps.sha-check.outputs.stale != 'true'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-meta.outputs.base }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Load historical baselines from perf-data branch
if: steps.sha-check.outputs.stale != 'true'
continue-on-error: true
run: |
mkdir -p temp/perf-history
git fetch origin perf-data 2>/dev/null || {
echo "perf-data branch not found, skipping historical data"
exit 0
}
INDEX=0
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
DIR="temp/perf-history/$INDEX"
mkdir -p "$DIR"
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
INDEX=$((INDEX + 1))
done
echo "Loaded $INDEX historical baselines"
- name: Generate perf report
if: steps.sha-check.outputs.stale != 'true'
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Post PR comment
if: steps.sha-check.outputs.stale != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./perf-report.md
comment-marker: '<!-- COMFYUI_FRONTEND_PERF -->'
token: ${{ secrets.GITHUB_TOKEN }}

1008
.github/workflows/pr-qa.yaml vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -180,7 +180,7 @@ jobs:
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
git fetch origin perf-data --depth=1
mkdir -p temp/perf-history
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
done
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"

133
.github/workflows/pr-size-report.yaml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: 'PR: Size Report'
on:
workflow_run:
workflows: ['CI: Size Data']
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to report on'
required: true
type: number
run_id:
description: 'Size data workflow run ID'
required: true
type: string
permissions:
contents: read
pull-requests: write
issues: write
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download size data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: size-data
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
path: temp/size
- name: Resolve and validate PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
// workflow_dispatch: validate artifact metadata against API-resolved PR
if (context.eventName === 'workflow_dispatch') {
const pullNumber = Number('${{ inputs.pr_number }}');
const { data: dispatchPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullNumber,
});
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
if (artifactPr !== dispatchPr.number) {
core.setFailed(`Artifact PR number (${artifactPr}) does not match dispatch PR (${dispatchPr.number}).`);
return;
}
if (artifactBase !== dispatchPr.base.ref) {
core.setFailed(`Artifact base (${artifactBase}) does not match dispatch PR base (${dispatchPr.base.ref}).`);
return;
}
core.setOutput('number', String(dispatchPr.number));
core.setOutput('base', dispatchPr.base.ref);
return;
}
// workflow_run: validate artifact metadata against trusted context
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.setFailed('Unable to resolve PR from workflow_run context.');
return;
}
if (Number(pr.number) !== artifactPr) {
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
return;
}
const trustedBase = pr.base?.ref;
if (!trustedBase || artifactBase !== trustedBase) {
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
return;
}
core.setOutput('number', String(pr.number));
core.setOutput('base', trustedBase);
- name: Download previous size data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-meta.outputs.base }}
workflow: ci-size-data.yaml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn
- name: Generate size report
run: node scripts/size-report.js > size-report.md
- name: Post PR comment
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./size-report.md
comment-marker: '<!-- COMFYUI_FRONTEND_SIZE -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -84,7 +84,9 @@ jobs:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -75,7 +75,9 @@ jobs:
path: comfyui
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -200,7 +202,9 @@ jobs:
ref: v${{ needs.resolve-version.outputs.target_version }}
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v6
with:

View File

@@ -20,10 +20,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -75,7 +75,9 @@ jobs:
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -30,7 +30,6 @@ concurrency:
jobs:
bump-version:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -143,7 +142,9 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -51,7 +51,9 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -18,7 +18,6 @@ concurrency:
jobs:
docs-check:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
@@ -29,7 +28,9 @@ jobs:
ref: main
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

4
.gitignore vendored
View File

@@ -99,4 +99,6 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.playwright-cli/
.playwright/

View File

@@ -97,13 +97,6 @@
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},

View File

@@ -208,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns.
3. Keep your module mocks contained
Do not use global mutable state within the test file
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
5. Aim for behavioral coverage of critical and new features
### Playwright / Browser / E2E Tests
@@ -216,7 +216,6 @@ See @docs/testing/\*.md for detailed patterns.
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
## External Resources
@@ -232,18 +231,6 @@ See @docs/testing/\*.md for detailed patterns.
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records
All architectural decisions are documented in `docs/adr/`. Code changes must be consistent with accepted ADRs. Proposed ADRs indicate design direction and should be treated as guidance. See `.agents/checks/adr-compliance.md` for automated validation rules.
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
## Project Philosophy
- Follow good software engineering principles

View File

@@ -51,9 +51,6 @@
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/
.cursor/

View File

@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
/** State of a maintenance task, managed by the maintenance task store. */
class MaintenanceTaskRunner {
export class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}
private _state?: MaintenanceTaskState

View File

@@ -1,6 +1,6 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { config as dotenvConfig } from 'dotenv'
import dotenv from 'dotenv'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
dotenvConfig()
dotenv.config()
const projectRoot = fileURLToPath(new URL('.', import.meta.url))

View File

@@ -1,2 +0,0 @@
dist/
.astro/

View File

@@ -1,24 +0,0 @@
import { defineConfig } from 'astro/config'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
integrations: [vue()],
vite: {
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
},
i18n: {
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false
}
}
})

View File

@@ -1,80 +0,0 @@
{
"name": "@comfyorg/website",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@comfyorg/design-system": "workspace:*",
"@vercel/analytics": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/vue": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
}
}
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028ZM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38Zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z"/></svg>

Before

Width:  |  Height:  |  Size: 819 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z"/></svg>

Before

Width:  |  Height:  |  Size: 988 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>

Before

Width:  |  Height:  |  Size: 536 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm5.8 11.33c.02.16.03.33.03.5 0 2.55-2.97 4.63-6.63 4.63-3.65 0-6.62-2.07-6.62-4.63 0-.17.01-.34.03-.5a1.58 1.58 0 0 1-.63-1.27c0-.88.72-1.59 1.6-1.59.44 0 .83.18 1.12.46 1.1-.79 2.62-1.3 4.31-1.37l.73-3.44a.32.32 0 0 1 .39-.24l2.43.52a1.13 1.13 0 0 1 2.15.36 1.13 1.13 0 0 1-1.13 1.12 1.13 1.13 0 0 1-1.08-.82l-2.16-.46-.65 3.07c1.65.09 3.14.59 4.22 1.36.29-.28.69-.46 1.13-.46.88 0 1.6.71 1.6 1.59 0 .52-.25.97-.63 1.27ZM9.5 13.5c0 .63.51 1.13 1.13 1.13s1.12-.5 1.12-1.13-.5-1.12-1.12-1.12-1.13.5-1.13 1.12Zm5.75 2.55c-.69.69-2 .73-3.25.73s-2.56-.04-3.25-.73a.32.32 0 1 1 .45-.45c.44.44 1.37.6 2.8.6 1.43 0 2.37-.16 2.8-.6a.32.32 0 1 1 .45.45Zm-.37-1.42c.62 0 1.13-.5 1.13-1.13 0-.62-.51-1.12-1.13-1.12-.63 0-1.13.5-1.13 1.12 0 .63.5 1.13 1.13 1.13Z"/></svg>

Before

Width:  |  Height:  |  Size: 915 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>

Before

Width:  |  Height:  |  Size: 254 B

View File

@@ -1,143 +0,0 @@
<script setup lang="ts">
const columns = [
{
title: 'Product',
links: [
{ label: 'Comfy Desktop', href: '/download' },
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
{ label: 'Pricing', href: '/pricing' }
]
},
{
title: 'Resources',
links: [
{ label: 'Documentation', href: 'https://docs.comfy.org' },
{ label: 'Blog', href: 'https://blog.comfy.org' },
{ label: 'Gallery', href: '/gallery' },
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
]
},
{
title: 'Company',
links: [
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
]
},
{
title: 'Legal',
links: [
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
]
}
]
const socials = [
{
label: 'GitHub',
href: 'https://github.com/comfyanonymous/ComfyUI',
icon: '/icons/social/github.svg'
},
{
label: 'Discord',
href: 'https://discord.gg/comfyorg',
icon: '/icons/social/discord.svg'
},
{
label: 'X',
href: 'https://x.com/comaboratory',
icon: '/icons/social/x.svg'
},
{
label: 'Reddit',
href: 'https://reddit.com/r/comfyui',
icon: '/icons/social/reddit.svg'
},
{
label: 'LinkedIn',
href: 'https://linkedin.com/company/comfyorg',
icon: '/icons/social/linkedin.svg'
},
{
label: 'Instagram',
href: 'https://instagram.com/comfyorg',
icon: '/icons/social/instagram.svg'
}
]
</script>
<template>
<footer class="border-t border-white/10 bg-black">
<div
class="mx-auto grid max-w-7xl gap-8 px-6 py-16 sm:grid-cols-2 lg:grid-cols-5"
>
<!-- Brand -->
<div class="lg:col-span-1">
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
Comfy
</a>
<p class="mt-4 text-sm text-smoke-700">
Professional control of visual AI.
</p>
</div>
<!-- Link columns -->
<nav
v-for="column in columns"
:key="column.title"
:aria-label="column.title"
class="flex flex-col gap-3"
>
<h3 class="text-sm font-semibold text-white">{{ column.title }}</h3>
<a
v-for="link in column.links"
:key="link.href"
:href="link.href"
class="text-sm text-smoke-700 transition-colors hover:text-white"
>
{{ link.label }}
</a>
</nav>
</div>
<!-- Bottom bar -->
<div class="border-t border-white/10">
<div
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
>
<p class="text-sm text-smoke-700">
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
</p>
<!-- Social icons -->
<div class="flex items-center gap-4">
<a
v-for="social in socials"
:key="social.label"
:href="social.href"
:aria-label="social.label"
target="_blank"
rel="noopener noreferrer"
class="text-smoke-700 transition-colors hover:text-white"
>
<span
class="inline-block size-5 bg-current"
:style="{
maskImage: `url(${social.icon})`,
maskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskImage: `url(${social.icon})`,
WebkitMaskSize: 'contain',
WebkitMaskRepeat: 'no-repeat'
}"
aria-hidden="true"
/>
</a>
</div>
</div>
</div>
</footer>
</template>

View File

@@ -1,149 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const mobileMenuOpen = ref(false)
const currentPath = ref('')
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
const ctaLinks = [
{
label: 'COMFY CLOUD',
href: 'https://app.comfy.org',
primary: true
},
{
label: 'COMFY HUB',
href: 'https://hub.comfy.org',
primary: false
}
]
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && mobileMenuOpen.value) {
mobileMenuOpen.value = false
}
}
function onAfterSwap() {
mobileMenuOpen.value = false
currentPath.value = window.location.pathname
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
document.addEventListener('astro:after-swap', onAfterSwap)
currentPath.value = window.location.pathname
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
document.removeEventListener('astro:after-swap', onAfterSwap)
})
</script>
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
Comfy
</a>
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
>
{{ link.label }}
</a>
<div class="flex items-center gap-3">
<a
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
"
class="rounded-full px-5 py-2 text-sm font-semibold"
>
{{ cta.label }}
</a>
</div>
</div>
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
aria-label="Toggle menu"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && 'translate-y-2 rotate-45'"
/>
<span
class="block h-0.5 w-6 bg-white transition-opacity"
:class="mobileMenuOpen && 'opacity-0'"
/>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && '-translate-y-2 -rotate-45'"
/>
</button>
</div>
<!-- Mobile menu -->
<div
v-show="mobileMenuOpen"
id="site-mobile-menu"
class="border-t border-white/10 bg-black px-6 pb-6 md:hidden"
>
<div class="flex flex-col gap-4 pt-4">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
@click="mobileMenuOpen = false"
>
{{ link.label }}
</a>
<div class="flex flex-col gap-3 pt-2">
<a
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
"
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
>
{{ cta.label }}
</a>
</div>
</div>
</div>
</nav>
</template>

View File

@@ -1 +0,0 @@
/// <reference types="astro/client" />

View File

@@ -1,86 +0,0 @@
---
import { ClientRouter } from 'astro:transitions'
import Analytics from '@vercel/analytics/astro'
import '../styles/global.css'
interface Props {
title: string
description?: string
ogImage?: string
}
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
ogImage = '/og-default.png',
} = Astro.props
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const locale = Astro.currentLocale ?? 'en'
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImageURL.href} />
<meta property="og:url" content={canonicalURL.href} />
<meta property="og:locale" content={locale} />
<meta property="og:site_name" content="Comfy" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Google Tag Manager -->
{gtmEnabled && (
<script is:inline define:vars={{ gtmId }}>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' })
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', gtmId)
</script>
)}
<ClientRouter />
</head>
<body class="bg-black text-white font-inter antialiased">
{gtmEnabled && (
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
height="0"
width="0"
style="display:none;visibility:hidden"
></iframe>
</noscript>
)}
<slot />
<Analytics />
</body>
</html>

View File

@@ -1,2 +0,0 @@
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';

View File

@@ -1,9 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.ts"]
}

View File

@@ -12,13 +12,12 @@ browser_tests/
│ ├── ComfyMouse.ts - Mouse interaction helper
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
│ ├── components/ - Page object components (locators, user interactions)
│ ├── components/ - Page object components
│ │ ├── ContextMenu.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ └── Topbar.ts
│ ├── helpers/ - Focused helper classes (domain-specific actions)
│ ├── helpers/ - Focused helper classes
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
│ │ ├── KeyboardHelper.ts
@@ -26,44 +25,11 @@ browser_tests/
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Pure utility functions (no page dependency)
│ └── utils/ - Utility functions
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
- **`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.
## 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).
```typescript
// ✅ Correct — single async call + single assertion
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
.toBe(0)
// ❌ Avoid — nested expect inside toPass
await expect(async () => {
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 250 })
```
Reserve `toPass()` for blocks with multiple assertions or complex async logic that can't be expressed as a single polled value.
## Gotchas
| Symptom | Cause | Fix |
| -------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `subtree intercepts pointer events` on DOM widgets | Canvas `z-999` overlay intercepts `click()` | Use Playwright's `locator.dispatchEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })` |
| Context menu empty or wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
| `navigateIntoSubgraph` timeout | Node too small in test asset JSON | Use node size `[400, 200]` minimum |
## After Making Changes
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory

View File

@@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example
**Always use UI mode for development:**
```bash
pnpm test:browser:local --ui
pnpm exec playwright test --ui
```
UI mode features:
@@ -91,8 +91,29 @@ UI mode features:
For CI or headless testing:
```bash
pnpm test:browser:local # Run all tests
pnpm test:browser:local widget.spec.ts # Run specific test file
pnpm exec playwright test # Run all tests
pnpm exec playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
For debugging, you can try adjusting these settings in `playwright.config.ts`:
```typescript
export default defineConfig({
// VERY HELPFUL: Skip screenshot tests locally
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
timeout: 30000, // Longer timeout for breakpoints
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
},
})
```
## Test Structure
@@ -364,7 +385,7 @@ export default defineConfig({
Option 2 - Generate local baselines for comparison:
```bash
pnpm test:browser:local --update-snapshots
pnpm exec playwright test --update-snapshots
```
### Creating New Screenshot Baselines

View File

@@ -1,169 +0,0 @@
{
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"pos": [400, 300],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 120,
"lastLinkId": 276,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Slot Drift Duplicate Links",
"inputNode": {
"id": -10,
"bounding": [0, 300, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 300, 120, 60]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 120,
"type": "ComfySwitchNode",
"title": "Switch (CFG)",
"pos": [100, 100],
"size": [200, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [257, 271, 276]
}
],
"properties": { "Node name for S&R": "ComfySwitchNode" },
"widgets_values": []
},
{
"id": 85,
"type": "KSamplerAdvanced",
"pos": [400, 50],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null },
{ "name": "steps", "type": "INT", "link": null },
{ "name": "cfg", "type": "FLOAT", "link": 276 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": { "Node name for S&R": "KSamplerAdvanced" },
"widgets_values": [
false,
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
false
]
},
{
"id": 86,
"type": "KSamplerAdvanced",
"pos": [400, 350],
"size": [270, 262],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null },
{ "name": "steps", "type": "INT", "link": null },
{ "name": "cfg", "type": "FLOAT", "link": 271 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": { "Node name for S&R": "KSamplerAdvanced" },
"widgets_values": [
false,
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
false
]
}
],
"groups": [],
"links": [
{
"id": 257,
"origin_id": 120,
"origin_slot": 0,
"target_id": 85,
"target_slot": 5,
"type": "FLOAT"
},
{
"id": 271,
"origin_id": 120,
"origin_slot": 0,
"target_id": 86,
"target_slot": 5,
"type": "FLOAT"
},
{
"id": 276,
"origin_id": 120,
"origin_slot": 0,
"target_id": 85,
"target_slot": 5,
"type": "FLOAT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": { "scale": 1, "offset": [0, 0] },
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -1,172 +0,0 @@
{
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 4,
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"pos": [689.0083557128902, 467.9999999999997],
"size": [431.8999938964844, 206.60000610351562],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["3", "text", "2"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [330, 367, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [983, 367, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"pos": [510, 166],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["11111111111"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [523, 438],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["22222222222"]
}
],
"groups": [],
"links": [],
"extra": {}
},
{
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [467, 446, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [932, 446, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"pos": [647, 389],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "text"],
["2", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 2.0975,
"offset": [-581.4780189305006, -356.3000030517576]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -1,817 +0,0 @@
{
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
"revision": 0,
"last_node_id": 61,
"last_link_id": 70,
"nodes": [
{
"id": 35,
"type": "MarkdownNote",
"pos": [-424.0076397768001, 199.99406275798367],
"size": [510, 774],
"flags": {
"collapsed": false
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Model link",
"properties": {},
"widgets_values": [
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ ├── 📂 text_encoders/\n│ │ └── qwen_3_4b.safetensors\n│ ├── 📂 loras/\n│ │ └── pixel_art_style_z_image_turbo.safetensors\n│ ├── 📂 diffusion_models/\n│ │ └── z_image_turbo_bf16.safetensors\n│ └── 📂 vae/\n│ └── ae.safetensors\n```\n"
],
"color": "#432",
"bgcolor": "#000"
},
{
"id": 9,
"type": "SaveImage",
"pos": [569.9875743118757, 199.99406275798367],
"size": [780, 660],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 62
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z-image-turbo"]
},
{
"id": 57,
"type": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"pos": [128.01215102992103, 199.99406275798367],
"size": [400, 470],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "prompt",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [62]
}
],
"properties": {
"proxyWidgets": [
["27", "text"],
["13", "width"],
["13", "height"],
["28", "unet_name"],
["30", "clip_name"],
["29", "vae_name"],
["3", "steps"],
["3", "control_after_generate"]
],
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
}
],
"links": [[62, 57, 0, 9, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"version": 1,
"state": {
"lastGroupId": 4,
"lastNodeId": 61,
"lastLinkId": 70,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Text to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [-80, 425, 120, 180]
},
"outputNode": {
"id": -20,
"bounding": [1490, 415, 120, 60]
},
"inputs": [
{
"id": "fb178669-e742-4a53-8a69-7df59834dfd8",
"name": "text",
"type": "STRING",
"linkIds": [34],
"label": "prompt",
"pos": [20, 445]
},
{
"id": "dd780b3c-23e9-46ff-8469-156008f42e5a",
"name": "width",
"type": "INT",
"linkIds": [35],
"pos": [20, 465]
},
{
"id": "7b08d546-6bb0-4ef9-82e9-ffae5e1ee6bc",
"name": "height",
"type": "INT",
"linkIds": [36],
"pos": [20, 485]
},
{
"id": "8ed4eb73-a2bf-4766-8bf4-c5890b560596",
"name": "unet_name",
"type": "COMBO",
"linkIds": [38],
"pos": [20, 505]
},
{
"id": "f362d639-d412-4b5d-8490-1e9995dc5f82",
"name": "clip_name",
"type": "COMBO",
"linkIds": [39],
"pos": [20, 525]
},
{
"id": "ee25ac16-de63-4b74-bbbb-5b29fdc1efcf",
"name": "vae_name",
"type": "COMBO",
"linkIds": [40],
"pos": [20, 545]
},
{
"id": "51cbcd61-9218-4bcb-89ac-ecdfb1ef8892",
"name": "steps",
"type": "INT",
"linkIds": [70],
"pos": [20, 565]
}
],
"outputs": [
{
"id": "1fa72a21-ce00-4952-814e-1f2ffbe87d1d",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [16],
"localized_name": "IMAGE",
"pos": [1510, 435]
}
],
"widgets": [],
"nodes": [
{
"id": 30,
"type": "CLIPLoader",
"pos": [110, 330],
"size": [270, 106],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "clip_name",
"name": "clip_name",
"type": "COMBO",
"widget": {
"name": "clip_name"
},
"link": 39
}
],
"outputs": [
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [28]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["qwen_3_4b.safetensors", "lumina2", "default"]
},
{
"id": 29,
"type": "VAELoader",
"pos": [110, 480],
"size": [270, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": 40
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [27]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["ae.safetensors"]
},
{
"id": 33,
"type": "ConditioningZeroOut",
"pos": [640, 620],
"size": [204.134765625, 26],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"link": 32
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [33]
}
],
"properties": {
"Node name for S&R": "ConditioningZeroOut",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1220, 160],
"size": [210, 46],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 27
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [16]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 28,
"type": "UNETLoader",
"pos": [110, 200],
"size": [270, 82],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "unet_name",
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 38
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [26]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z_image_turbo_bf16.safetensors", "default"]
},
{
"id": 27,
"type": "CLIPTextEncode",
"pos": [430, 200],
"size": [410, 370],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 28
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 34
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [30, 32]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"Latina female with thick wavy hair, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up. "
]
},
{
"id": 13,
"type": "EmptySD3LatentImage",
"pos": [110, 630],
"size": [260, 110],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": 35
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": 36
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [17]
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [1024, 1024, 1]
},
{
"id": 11,
"type": "ModelSamplingAuraFlow",
"pos": [880, 160],
"size": [310, 60],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 26
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [3]
},
{
"id": 3,
"type": "KSampler",
"pos": [880, 270],
"size": [315, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 30
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 33
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 17
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": 70
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [14]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
0,
"randomize",
8,
1,
"res_multistep",
"simple",
1
]
}
],
"groups": [
{
"id": 2,
"title": "Step2 - Image size",
"bounding": [100, 560, 290, 200],
"color": "#3f789e",
"flags": {}
},
{
"id": 3,
"title": "Step3 - Prompt",
"bounding": [410, 130, 450, 540],
"color": "#3f789e",
"flags": {}
},
{
"id": 4,
"title": "Step1 - Load models",
"bounding": [100, 130, 290, 413.6],
"color": "#3f789e",
"flags": {}
}
],
"links": [
{
"id": 32,
"origin_id": 27,
"origin_slot": 0,
"target_id": 33,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 26,
"origin_id": 28,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 27,
"origin_id": 29,
"origin_slot": 0,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 13,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 30,
"origin_id": 27,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 33,
"origin_id": 33,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 17,
"origin_id": 13,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 28,
"origin_id": 30,
"origin_slot": 0,
"target_id": 27,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 16,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 34,
"origin_id": -10,
"origin_slot": 0,
"target_id": 27,
"target_slot": 1,
"type": "STRING"
},
{
"id": 35,
"origin_id": -10,
"origin_slot": 1,
"target_id": 13,
"target_slot": 0,
"type": "INT"
},
{
"id": 36,
"origin_id": -10,
"origin_slot": 2,
"target_id": 13,
"target_slot": 1,
"type": "INT"
},
{
"id": 38,
"origin_id": -10,
"origin_slot": 3,
"target_id": 28,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 39,
"origin_id": -10,
"origin_slot": 4,
"target_id": 30,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 40,
"origin_id": -10,
"origin_slot": 5,
"target_id": 29,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 70,
"origin_id": -10,
"origin_slot": 6,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "LG"
}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6488294314381271,
"offset": [733, 392.7886597938144]
},
"frontendVersion": "1.43.4",
"workflowRendererVersion": "LG",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

View File

@@ -1,599 +0,0 @@
{
"id": "legacy-prefix-test-workflow",
"revision": 0,
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [["6", "6: 3: string_a"]]
},
"widgets_values": [""]
},
{
"id": 2,
"type": "PreviewAny",
"pos": [1335, 429],
"size": [250, 145.5],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, false]
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [356, 450],
"size": [225, 121.5],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 6,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1315, 432.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1335, 452.5]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "StringConcatenate",
"pos": [815, 373],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [955, 775],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [313, 685],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 12,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [680, 774, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1320, 774, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [780, 794]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1340, 794]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "StringConcatenate",
"pos": [860, 719],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "PrimitiveStringMultiline",
"pos": [401, 973],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1046, 985],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 6,
"origin_slot": 0,
"target_id": 5,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 5,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Innermost Subgraph",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1330, 1222, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1350, 1242]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "StringConcatenate",
"pos": [870, 1038],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [442, 1296],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-7, 144]
},
"frontendVersion": "1.38.13"
},
"version": 0.4
}

View File

@@ -1,555 +0,0 @@
{
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
"revision": 0,
"last_node_id": 11,
"last_link_id": 16,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": [1451.0058559453123, 189.0019842294924],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [25.988896564209426, 473.9973077158204],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [11]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [10]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"pos": [711.776576770508, 420.55569028417983],
"size": [400, 293],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 10
},
{
"name": "model",
"type": "MODEL",
"link": 11
},
{
"name": "vae",
"type": "VAE",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [13]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"],
["3", "seed"]
]
},
"widgets_values": []
}
],
"links": [
[10, 4, 1, 10, 0, "CLIP"],
[11, 4, 0, 10, 1, "MODEL"],
[12, 4, 2, 10, 2, "VAE"],
[13, 10, 0, 9, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233, 404.5, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1494, 424.5, 120, 60]
},
"inputs": [
{
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
"name": "clip",
"type": "CLIP",
"linkIds": [14],
"localized_name": "clip",
"pos": [333, 424.5]
},
{
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [333, 444.5]
},
{
"id": "e61199fa-9113-4532-a3d9-879095969171",
"name": "vae",
"type": "VAE",
"linkIds": [8],
"localized_name": "vae",
"pos": [333, 464.5]
}
],
"outputs": [
{
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [9],
"localized_name": "IMAGE",
"pos": [1514, 444.5]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473.007643669922, 609.0214689174805],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [862.990643669922, 185.9853293300783],
"size": [400, 317],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 16
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209.0062878349609, 188.00400724755877],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 11,
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"pos": [485.5190761650391, 283.9247189174806],
"size": [400, 237],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [15]
},
{
"localized_name": "CONDITIONING_1",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"links": [16]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 2,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 9,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": 11,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
},
{
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
]
},
"inputs": [
{
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
"name": "clip",
"type": "CLIP",
"linkIds": [5, 3],
"localized_name": "clip",
"pos": [55, 20]
}
],
"outputs": [
{
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "CONDITIONING",
"pos": [20, 20]
},
{
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "CONDITIONING_1",
"pos": [20, 40]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413.01228575000005, 388.98593823266606],
"size": [425, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [414.99053247091683, 185.9946096918335],
"size": [423, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6830134553650709,
"offset": [-203.70966200000038, 259.92420099999975]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -1,407 +0,0 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -1,42 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "ImageCompare",
"pos": [50, 50],
"size": [400, 350],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "a_images",
"type": "IMAGE",
"link": null
},
{
"name": "b_images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "ImageCompare"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -1,48 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Painter",
"pos": [50, 50],
"size": [450, 550],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -5,7 +5,7 @@ import type {
Page
} from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import { config as dotenvConfig } from 'dotenv'
import dotenv from 'dotenv'
import { TestIds } from './selectors'
import { NodeBadgeMode } from '../../src/types/nodeSource'
@@ -19,12 +19,10 @@ import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
@@ -35,15 +33,13 @@ import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
import type { NodeReference } from './utils/litegraphUtils'
import { assetPath } from './utils/paths'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
dotenv.config()
class ComfyPropertiesPanel {
readonly root: Locator
@@ -58,7 +54,6 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
@@ -82,11 +77,6 @@ class ComfyMenu {
return this._nodeLibraryTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
}
get workflowsTab() {
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
return this._workflowsTab
@@ -186,7 +176,6 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -201,7 +190,6 @@ export class ComfyPage {
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
@@ -233,22 +221,20 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)
this.settings = new SettingsHelper(page)
this.keyboard = new KeyboardHelper(page, this.canvas)
this.clipboard = new ClipboardHelper(this.keyboard, page)
this.clipboard = new ClipboardHelper(this.keyboard)
this.workflow = new WorkflowHelper(this)
this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page)
this.dragDrop = new DragDropHelper(page)
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
}
@@ -301,7 +287,9 @@ export class ComfyPage {
clearStorage?: boolean
mockReleases?: boolean
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
await this.goto()
// Mock release endpoint to prevent changelog popups
if (mockReleases) {
await this.page.route('**/releases**', async (route) => {
const url = route.request().url()
@@ -321,16 +309,12 @@ export class ComfyPage {
}
if (clearStorage) {
// Navigate to a lightweight same-origin endpoint to obtain a page
// context for clearing storage without loading the full frontend app.
await this.page.goto(`${this.url}/api/users`)
await this.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
}
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
@@ -344,9 +328,8 @@ export class ComfyPage {
await this.nextFrame()
}
/** @deprecated Use standalone `assetPath` from `browser_tests/fixtures/utils/assetPath` directly. */
public assetPath(fileName: string) {
return assetPath(fileName)
return `./browser_tests/assets/${fileName}`
}
async goto() {
@@ -464,13 +447,12 @@ export const comfyPageFixture = base.extend<{
await comfyPage.setup()
const needsPerf =
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')
if (needsPerf) await comfyPage.perf.init()
const isPerf = testInfo.tags.includes('@perf')
if (isPerf) await comfyPage.perf.init()
await use(comfyPage)
if (needsPerf) await comfyPage.perf.dispose()
if (isPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)

View File

@@ -31,14 +31,10 @@ export class VueNodeHelpers {
}
/**
* 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.
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
* Get locator for a Vue node by the node's title (displayed name in the header)
*/
getNodeByTitle(title: string): Locator {
return this.page.locator('[data-node-id]').filter({
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
})
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
}
/**

View File

@@ -85,10 +85,7 @@ export class ComfyNodeSearchBox {
}
async removeFilter(index: number) {
await this.filterChips
.nth(index)
.getByRole('button', { name: 'Remove' })
.click()
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
/**

View File

@@ -1,4 +1,3 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
@@ -34,20 +33,6 @@ export class ContextMenu {
return primeVueVisible || litegraphVisible
}
async assertHasItems(items: string[]): Promise<void> {
for (const item of items) {
await expect
.soft(this.page.getByRole('menuitem', { name: item }))
.toBeVisible()
}
}
async openFor(locator: Locator): Promise<this> {
await locator.click({ button: 'right' })
await expect.poll(() => this.isVisible()).toBe(true)
return this
}
async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()

View File

@@ -168,32 +168,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
}
}
export class AssetsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'assets')
}
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
get importedTab() {
return this.page.getByRole('tab', { name: 'Imported' })
}
get emptyStateMessage() {
return this.page.getByText(
'Upload files or generate content to see them here'
)
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
override async open() {
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
}

View File

@@ -1,96 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import { BuilderFooterHelper } from './BuilderFooterHelper'
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
import { BuilderSelectHelper } from './BuilderSelectHelper'
import { BuilderStepsHelper } from './BuilderStepsHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
}
private get page(): Page {
return this.comfyPage.page
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await this.comfyPage.nextFrame()
}
/**
* Inject linearData into the current graph and enter app mode.
*
* Serializes the graph, injects linearData with the given inputs and
* auto-detected output node IDs, then reloads so the appModeStore
* picks up the data via its activeWorkflow watcher.
*
* @param inputs - Widget selections as [nodeId, widgetName] tuples
*/
async enterAppModeWithInputs(inputs: [string, string][]) {
await this.page.evaluate(async (inputTuples) => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
}, inputs)
await this.comfyPage.nextFrame()
await this.toggleAppMode()
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
}
/**
* 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").
*/
getAppModeWidgetMenu(widgetName: string): Locator {
return this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
}

View File

@@ -1,147 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {}
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = [...this.generatedJobs]
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
})
}
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
}
}

View File

@@ -1,69 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class BuilderFooterHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
get nav(): Locator {
return this.page.getByTestId(TestIds.builder.footerNav)
}
get exitButton(): Locator {
return this.buttonByName('Exit app builder')
}
get nextButton(): Locator {
return this.buttonByName('Next')
}
get backButton(): Locator {
return this.buttonByName('Back')
}
get saveButton(): Locator {
return this.page.getByTestId(TestIds.builder.saveButton)
}
get 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 })
}
async next() {
await this.nextButton.click()
await this.comfyPage.nextFrame()
}
async back() {
await this.backButton.click()
await this.comfyPage.nextFrame()
}
async exitBuilder() {
await this.exitButton.click()
await this.comfyPage.nextFrame()
}
async openSaveAsFromChevron() {
await this.saveAsChevron.click()
await this.page.getByRole('menuitem', { name: 'Save as' }).click()
await this.comfyPage.nextFrame()
}
}

View File

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

View File

@@ -1,139 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import type { NodeReference } from '../utils/litegraphUtils'
import { TestIds } from '../selectors'
export class BuilderSelectHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/**
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
* @param title The widget title shown in the IoItem.
*/
getInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({
has: this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
})
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the preview/arrange sidebar.
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.getByLabel(ariaLabel, { exact: true })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** Delete a builder input via its actions menu. */
async deleteInput(title: string) {
const menu = this.getInputItemMenu(title)
await menu.click()
await this.page.getByText('Delete', { exact: true }).click()
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameInputViaMenu(title: string, newName: string) {
const menu = this.getInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title for inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a widget via its actions popover (works in preview and app mode).
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async selectInputWidget(node: NodeReference) {
await this.comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await this.page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await this.comfyPage.nextFrame()
}
/** Click the first SaveImage/PreviewImage node on the canvas. */
async selectOutputNode() {
const saveImageNodeId = await this.page.evaluate(() => {
const node = window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
return node ? String(node.id) : null
})
if (!saveImageNodeId)
throw new Error('SaveImage/PreviewImage node not found')
const saveImageRef =
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await this.page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await this.comfyPage.nextFrame()
}
}

View File

@@ -1,30 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
export class BuilderStepsHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
get toolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
async goToInputs() {
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
async goToOutputs() {
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
async goToPreview() {
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
}

View File

@@ -91,12 +91,6 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale
@@ -169,39 +163,6 @@ export class CanvasHelper {
})
}
/**
* Pan the canvas back and forth in a sweep pattern using middle-mouse drag.
* Each step advances one animation frame, giving per-frame measurement
* granularity for performance tests.
*/
async panSweep(options?: {
steps?: number
dx?: number
dy?: number
}): Promise<void> {
const { steps = 120, dx = 8, dy = 3 } = options ?? {}
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await this.page.mouse.move(centerX, centerY)
await this.page.mouse.down({ button: 'middle' })
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,

View File

@@ -1,16 +1,9 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { KeyboardHelper } from './KeyboardHelper'
import { getMimeType } from './mimeTypeUtil'
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
private readonly page: Page
) {}
constructor(private readonly keyboard: KeyboardHelper) {}
async copy(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyC', locator ?? null)
@@ -19,44 +12,4 @@ export class ClipboardHelper {
async paste(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
}
}

View File

@@ -3,11 +3,12 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '../types'
import { getMimeType } from './mimeTypeUtil'
import { assetPath } from '../utils/paths'
export class DragDropHelper {
constructor(private readonly page: Page) {}
constructor(
private readonly page: Page,
private readonly assetPath: (fileName: string) => string
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
@@ -23,15 +24,13 @@ export class DragDropHelper {
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false,
preserveNativePropagation = false
waitForUpload = false
} = options
if (!fileName && !url)
@@ -43,15 +42,25 @@ export class DragDropHelper {
fileType?: string
buffer?: Uint8Array | number[]
url?: string
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
} = { dropPosition }
if (fileName) {
const filePath = assetPath(fileName)
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.fileType = getFileType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -116,17 +125,15 @@ export class DragDropHelper {
)
}
if (!params.preserveNativePropagation) {
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
}
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
@@ -157,10 +164,7 @@ export class DragDropHelper {
async dragAndDropURL(
url: string,
options: {
dropPosition?: Position
preserveNativePropagation?: boolean
} = {}
options: { dropPosition?: Position } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}

View File

@@ -33,7 +33,6 @@ export class NodeOperationsHelper {
})
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}

View File

@@ -23,15 +23,12 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
eventListeners: number
totalBlockingTimeMs: number
frameDurationMs: number
p95FrameDurationMs: number
allFrameDurationsMs: number[]
}
export class PerformanceHelper {
@@ -103,13 +100,13 @@ export class PerformanceHelper {
}
/**
* Measure individual frame durations via rAF timing over a sample window.
* Returns all per-frame durations so callers can compute avg, p95, etc.
* Measure average frame duration via rAF timing over a sample window.
* Returns average ms per frame (lower = better, 16.67 = 60fps).
*/
private async measureFrameDurations(sampleFrames = 30): Promise<number[]> {
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
return this.page.evaluate((frames) => {
return new Promise<number[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 5000)
return new Promise<number>((resolve) => {
const timeout = setTimeout(() => resolve(0), 5000)
const timestamps: number[] = []
let count = 0
function tick(ts: number) {
@@ -120,14 +117,11 @@ export class PerformanceHelper {
} else {
clearTimeout(timeout)
if (timestamps.length < 2) {
resolve([])
resolve(0)
return
}
const durations: number[] = []
for (let i = 1; i < timestamps.length; i++) {
durations.push(timestamps[i] - timestamps[i - 1])
}
resolve(durations)
const total = timestamps[timestamps.length - 1] - timestamps[0]
resolve(total / (timestamps.length - 1))
}
}
requestAnimationFrame(tick)
@@ -182,21 +176,11 @@ export class PerformanceHelper {
return after[key] - before[key]
}
const [totalBlockingTimeMs, allFrameDurationsMs] = await Promise.all([
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
this.collectTBT(),
this.measureFrameDurations()
this.measureFrameDuration()
])
const frameDurationMs =
allFrameDurationsMs.length > 0
? allFrameDurationsMs.reduce((a, b) => a + b, 0) /
allFrameDurationsMs.length
: 0
const sorted = [...allFrameDurationsMs].sort((a, b) => a - b)
const p95FrameDurationMs =
sorted.length > 0 ? sorted[Math.ceil(sorted.length * 0.95) - 1] : 0
return {
name,
durationMs: delta('Timestamp') * 1000,
@@ -206,15 +190,12 @@ export class PerformanceHelper {
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize'),
heapUsedBytes: after.JSHeapUsedSize,
domNodes: delta('Nodes'),
jsHeapTotalBytes: delta('JSHeapTotalSize'),
scriptDurationMs: delta('ScriptDuration') * 1000,
eventListeners: delta('JSEventListeners'),
totalBlockingTimeMs,
frameDurationMs,
p95FrameDurationMs,
allFrameDurationsMs
frameDurationMs
}
}
}

View File

@@ -1,14 +1,11 @@
import { expect } from '@playwright/test'
import type { ConsoleMessage, Locator, Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
CanvasPointerEvent,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
@@ -325,227 +322,4 @@ export class SubgraphHelper {
)
await this.comfyPage.nextFrame()
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async exitViaBreadcrumb(): Promise<void> {
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
const parentLink = breadcrumb.getByRole('link').first()
if (await parentLink.isVisible()) {
await parentLink.click()
} else {
await this.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph
if (!graph) return
canvas.setGraph(graph.rootGraph)
})
}
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
}
async countGraphPseudoPreviewEntries(): Promise<number> {
return this.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.reduce((count, node) => {
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return count
return (
count +
proxyWidgets.filter(
(entry) =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[1] === 'string' &&
entry[1].startsWith('$$')
).length
)
}, 0)
})
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
async getNodeCount(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
async getSlotCount(type: 'input' | 'output'): Promise<number> {
return this.page.evaluate((slotType: 'input' | 'output') => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return 0
return graph[`${slotType}s`]?.length ?? 0
}, type)
}
async getSlotLabel(
type: 'input' | 'output',
index = 0
): Promise<string | null> {
return this.page.evaluate(
([slotType, idx]) => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const slot = graph[`${slotType}s`]?.[idx]
return slot?.label ?? slot?.name ?? null
},
[type, index] as const
)
}
async removeSlot(type: 'input' | 'output', slotName?: string): Promise<void> {
if (type === 'input') {
await this.rightClickInputSlot(slotName)
} else {
await this.rightClickOutputSlot(slotName)
}
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await this.comfyPage.nextFrame()
}
async findSubgraphNodeId(): Promise<string> {
const id = await this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const node = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return node ? String(node.id) : null
})
if (!id) throw new Error('No subgraph node found in current graph')
return id
}
async serializeAndReload(): Promise<void> {
const serialized = await this.page.evaluate(() =>
window.app!.graph!.serialize()
)
await this.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized as ComfyWorkflowJSON
)
await this.comfyPage.nextFrame()
}
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
await this.comfyPage.workflow.loadWorkflow('default')
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await this.comfyPage.nextFrame()
return subgraphNode
}
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click()
await this.comfyPage.canvas.press('Control+a')
await this.comfyPage.nextFrame()
await this.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await this.comfyPage.nextFrame()
await this.exitViaBreadcrumb()
await this.comfyPage.canvas.click()
await this.comfyPage.nextFrame()
}
static getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}
static async expectWidgetBelowHeader(
nodeLocator: Locator,
widgetLocator: Locator
): Promise<void> {
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await widgetLocator.boundingBox()
if (!headerBox || !widgetBox)
throw new Error('Header or widget bounding box not found')
expect(widgetBox.y).toBeGreaterThan(headerBox.y + headerBox.height)
}
static collectConsoleWarnings(
page: Page,
patterns: string[] = [
'No link found',
'Failed to resolve legacy -1',
'No inner link found'
]
): { warnings: string[]; dispose: () => void } {
const warnings: string[] = []
const handler = (msg: ConsoleMessage) => {
const text = msg.text()
if (patterns.some((p) => text.includes(p))) {
warnings.push(text)
}
}
page.on('console', handler)
return { warnings, dispose: () => page.off('console', handler) }
}
}

View File

@@ -1,13 +1,11 @@
import { readFileSync } from 'fs'
import type { AppMode } from '../../../src/composables/useAppMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { WorkspaceStore } from '../../types/globals'
import type { ComfyPage } from '../ComfyPage'
import { assetPath } from '../utils/paths'
type FolderStructure = {
[key: string]: FolderStructure | string
@@ -21,7 +19,7 @@ export class WorkflowHelper {
for (const [key, value] of Object.entries(structure)) {
if (typeof value === 'string') {
const filePath = assetPath(value)
const filePath = this.comfyPage.assetPath(value)
result[key] = readFileSync(filePath, 'utf-8')
} else {
result[key] = this.convertLeafToContent(value)
@@ -60,7 +58,7 @@ export class WorkflowHelper {
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)
this.comfyPage.assetPath(`${workflowName}.json`)
)
await this.comfyPage.nextFrame()
}
@@ -106,40 +104,6 @@ export class WorkflowHelper {
})
}
async getActiveWorkflowPath(): Promise<string | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.path
})
}
async getActiveWorkflowActiveAppMode(): Promise<AppMode | null | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.activeMode
})
}
async getActiveWorkflowInitialMode(): Promise<AppMode | null | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.initialMode
})
}
async getLinearModeFromGraph(): Promise<boolean | undefined> {
return this.comfyPage.page.evaluate(() => {
return window.app!.rootGraph.extra?.linearMode as boolean | undefined
})
}
async getOpenWorkflowCount(): Promise<number> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow.workflows
.length
})
}
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow

View File

@@ -1,13 +0,0 @@
export function getMimeType(fileName: string): string {
const name = fileName.toLowerCase()
if (name.endsWith('.png')) return 'image/png'
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg'
if (name.endsWith('.webp')) return 'image/webp'
if (name.endsWith('.svg')) return 'image/svg+xml'
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}

View File

@@ -20,13 +20,7 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
closeMinimapButton: 'close-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
zoomOutAction: 'zoom-out-action',
zoomToFitAction: 'zoom-to-fit-action',
zoomPercentageInput: 'zoom-percentage-input'
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
},
dialogs: {
settings: 'settings-dialog',
@@ -34,26 +28,14 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
errorOverlayDismiss: 'error-overlay-dismiss',
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
whatsNewSection: 'whats-new-section'
},
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button'
saveButton: 'save-workflow-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -71,23 +53,11 @@ export const TestIds = {
colorRed: 'red'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
saveAsButton: 'builder-save-as-button',
saveAsChevron: 'builder-save-as-chevron',
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu',
opensAs: 'builder-opens-as'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
@@ -97,10 +67,6 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -112,18 +78,15 @@ export type TestIdValue =
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -281,14 +281,6 @@ export class NodeReference {
getType(): Promise<string> {
return this.getProperty('type')
}
async centerOnNode(): Promise<void> {
await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
window.app!.canvas.centerOnNode(node)
}, this.id)
await this.comfyPage.nextFrame()
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
@@ -392,11 +384,6 @@ export class NodeReference {
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async delete(): Promise<void> {
await this.click('title')
await this.comfyPage.page.keyboard.press('Delete')
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,

View File

@@ -1,3 +0,0 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}

View File

@@ -29,8 +29,7 @@ export const webSocketFixture = base.extend<{
function ([data, url]) {
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.href)
u.hash = ''
const u = new URL(window.location.toString())
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'

View File

@@ -1,10 +1,11 @@
import { config as dotenvConfig } from 'dotenv'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { backupPath } from './utils/backupUtils'
dotenvConfig()
dotenv.config()
export default function globalSetup() {
export default function globalSetup(_config: FullConfig) {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])

View File

@@ -1,11 +1,12 @@
import { config as dotenvConfig } from 'dotenv'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenvConfig()
dotenv.config()
export default function globalTeardown() {
export default function globalTeardown(_config: FullConfig) {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {

View File

@@ -1,78 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
/**
* Enter builder on the default workflow and select I/O.
*
* Loads the default workflow, optionally transforms it (e.g. convert a node
* to subgraph), then enters builder mode and selects inputs + outputs.
*
* @param comfyPage - The page fixture.
* @param getInputNode - Returns the node to click for input selection.
* Receives the KSampler node ref and can transform the graph before
* returning the target node. Defaults to using KSampler directly.
* @returns The node used for input selection.
*/
export async function setupBuilder(
comfyPage: ComfyPage,
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
): Promise<NodeReference> {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
await appMode.select.selectInputWidget(inputNode)
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
return inputNode
}
/**
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
*
* Returns the subgraph node reference for further interaction.
*/
export async function setupSubgraphBuilder(
comfyPage: ComfyPage
): Promise<NodeReference> {
return setupBuilder(comfyPage, async (ksampler) => {
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
expect(promotedNames).toContain('seed')
return subgraphNode
})
}
/** Save the workflow, reopen it, and enter app mode. */
export async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}

View File

@@ -12,43 +12,10 @@ export interface PerfReport {
const TEMP_DIR = join('test-results', 'perf-temp')
type MeasurementField = keyof PerfMeasurement
const FIELD_FORMATTERS: Record<string, (m: PerfMeasurement) => string> = {
styleRecalcs: (m) => `${m.styleRecalcs} recalcs`,
layouts: (m) => `${m.layouts} layouts`,
taskDurationMs: (m) => `${m.taskDurationMs.toFixed(1)}ms task`,
layoutDurationMs: (m) => `${m.layoutDurationMs.toFixed(1)}ms layout`,
frameDurationMs: (m) => `${m.frameDurationMs.toFixed(1)}ms/frame`,
totalBlockingTimeMs: (m) => `TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`,
durationMs: (m) => `${m.durationMs.toFixed(0)}ms total`,
heapDeltaBytes: (m) => `heap Δ${(m.heapDeltaBytes / 1024).toFixed(0)}KB`,
domNodes: (m) => `DOM Δ${m.domNodes}`,
heapUsedBytes: (m) => `heap ${(m.heapUsedBytes / 1024 / 1024).toFixed(1)}MB`
}
/**
* Log a perf measurement to the console in a consistent format.
* Fields are formatted automatically based on their type.
*/
export function logMeasurement(
label: string,
m: PerfMeasurement,
fields: MeasurementField[]
) {
const parts = fields.map((f) => {
const formatter = FIELD_FORMATTERS[f]
if (formatter) return formatter(m)
return `${f}=${m[f]}`
})
console.log(`${label}: ${parts.join(', ')}`)
}
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
const { allFrameDurationsMs: _, ...serializable } = m
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(serializable))
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
}
export function writePerfReport(

View File

@@ -2,11 +2,6 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
@@ -37,28 +32,6 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -75,26 +48,6 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
export async function getPseudoPreviewWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -0,0 +1,16 @@
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
/**
* Assertion helper for tests where being in a subgraph is a precondition.
* Throws a clear error if the graph is not a Subgraph.
*/
export function assertSubgraph(
graph: LGraph | Subgraph | null | undefined
): asserts graph is Subgraph {
if (!isSubgraph(graph)) {
throw new Error(
'Expected to be in a subgraph context, but graph is not a Subgraph'
)
}
}

View File

@@ -1,168 +0,0 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
* All widgets from the default graph are selected so the panel scrolls,
* pushing the last widget's dropdown to the clipping boundary.
*/
const DEFAULT_INPUTS: [string, string][] = [
['4', 'ckpt_name'],
['6', 'text'],
['7', 'text'],
['5', 'width'],
['5', 'height'],
['5', 'batch_size'],
['3', 'seed'],
['3', 'steps'],
['3', 'cfg'],
['3', 'sampler_name'],
['3', 'scheduler'],
['3', 'denoise'],
['9', 'filename_prefix']
]
function isClippedByAnyAncestor(el: Element): boolean {
const child = el.getBoundingClientRect()
let parent = el.parentElement
while (parent) {
const overflow = getComputedStyle(parent).overflow
if (overflow !== 'visible') {
const p = parent.getBoundingClientRect()
if (
child.top < p.top ||
child.bottom > p.bottom ||
child.left < p.left ||
child.right > p.right
) {
return true
}
}
parent = parent.parentElement
}
return false
}
/** Add a node to the graph by type and return its ID. */
async function addNode(page: Page, nodeType: string): Promise<string> {
return page.evaluate((type) => {
const node = window.app!.graph.add(
window.LiteGraph!.createNode(type, undefined, {})
)
return String(node!.id)
}, nodeType)
}
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Select dropdown is not clipped in app mode panel', async ({
comfyPage
}) => {
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[saveVideoId, 'codec']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the codec widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
await expect(overlay).toBeVisible({ timeout: 5000 })
const isInViewport = await overlay.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
test('FormDropdown popup is not clipped in app mode panel', async ({
comfyPage
}) => {
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[loadImageId, 'image']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the image widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
.first()
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -1,109 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '../helpers/builderTestUtils'
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Rename from builder input-select sidebar via menu', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.steps.goToInputs()
const menu = appMode.select.getInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.footer.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-menu`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(
appMode.linearWidgets.getByText('Builder Input Seed')
).toBeVisible()
})
test('Rename from builder input-select sidebar via double-click', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.steps.goToInputs()
await appMode.select.renameInput('seed', 'Dblclick Seed')
await appMode.footer.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.steps.goToPreview()
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.select.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.footer.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
})
test('Rename from app mode', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.footer.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.select.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
// Verify persistence after save/reload
await appMode.toggleAppMode()
const workflowName = `${new Date().getTime()} app-mode`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
})
})

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