Compare commits

..

105 Commits

Author SHA1 Message Date
github-actions[bot]
161f4bf0b8 test: add QA regression test for PR #10336 2026-03-25 16:05:55 +00:00
snomiao
4310c5238a feat: clickable badges with #NUM label and copy button
- Badge generators accept optional label param (#NUM QA)
- Badge in PR/issue comments links to report site
- Report site shows badge with copy-to-clipboard button
- Copy button produces markdown: [![QA Badge](url/badge.svg)](url/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:34:23 +00:00
snomiao
a7d7d39712 fix: resolve CI failures — test, shellcheck, format
- Update DefaultThumbnail test to match size-full class change
- Fix shellcheck warnings in qa-batch.sh (SC2001, SC2207)
- Fix shellcheck warnings in qa-deploy-pages.sh (SC2034, SC2235, SC2231, SC2002)
- Add qa-report-template.html to oxfmt ignore (minified, not formattable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:33:29 +09:00
snomiao
24ac6f1566 fix: badge pattern too narrow/broad, multi-pass video discovery
- "confirmed" didn't match "confirms"/"reproducible" — use "reproduc|confirm" stem
- "partial" matched unrelated text — require "partially reproduced" specifically
- collectVideoCandidates now finds qa-session-*.mp4 for multi-pass reviews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:18:53 +00:00
snomiao
f9a5baba1a fix: badge mismatch, multi-pass report overwrite, agent node creation
- Check INCONCLUSIVE before reproduced/confirmed in badge detection
- Exclude markdown headings from reproduced grep match
- Add --pass-label to qa-video-review.ts for unique multi-pass filenames
- Pass pass label from workflow YAML when reviewing numbered sessions
- Collect all pass-specific reports in deploy script HTML
- Add addNode/cloneNode convenience actions to qa-record agent
- Improve strategy hints for visual/rendering bug reproduction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:48:38 +00:00
snomiao
628f64631b chore: tidy PR for merge — resolve TODOs, fix misplaced import
- Remove push trigger (was for dev testing only)
- Restore concurrency group (was commented out for dev)
- Move misplaced import in qa-analyze-pr.ts to top of file

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:40:55 +09:00
snomiao
bc38b2ce13 fix: extract deploy step into script to fix expression length limit
The Cloudflare Pages deploy step exceeded GitHub Actions' 21000 char
expression limit due to inline HTML/CSS/JS. Extract to
scripts/qa-deploy-pages.sh + scripts/qa-report-template.html.
2026-03-25 09:24:17 +00:00
snomiao
ca8e4d2a29 fix: enforce menu navigation pattern + add CI job link to report
- Strengthen prompt: MUST use openMenu → hoverMenuItem → clickMenuItem
  in that order. Previous runs skipped openMenu causing silent failures.
- Add CI Job link to the QA report site header for quick navigation
  to the GitHub Actions run that generated the report.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:46:46 +09:00
snomiao
55ce174c5b fix: split dual badge generator into separate step to fix expression length
GitHub Actions has a 21000 char limit per expression. The combined
badge setup step exceeded this after adding the dual badge generator.
Split into its own step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 08:37:23 +00:00
snomiao
49176271f8 chore: retrigger QA pipeline 2026-03-25 06:23:00 +00:00
snomiao
50518449fc fix: remove Bug: prefix from PR badge, just show REPRODUCED directly
Badge now reads: QA Bot | REPRODUCED | Fix: APPROVED
Not all issues are bugs — could be feature requests too.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:21:02 +09:00
snomiao
65aa03b20d fix: use blue for REPRODUCED badge (success, not failure)
Reproducing a bug is a successful outcome for the QA bot.
Blue (#2196f3) = bot succeeded. Red = bot found problems with the fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:19:28 +09:00
snomiao
ce59b6a431 feat: combine PR bug+fix into single dual-segment badge
PRs now show one badge with three segments:
  QA Bot | Bug: REPRODUCED | Fix: APPROVED

Instead of two separate badges. Uses gen-badge-dual.sh which
renders label + bug status + fix status in one SVG.

Issues still use single two-segment badge:
  QA Bot | FINISHED: REPRODUCED

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:14:35 +09:00
snomiao
203e8c8a60 feat: add visible cursor overlay and annotation action to QA recorder
- Inject fake cursor (red dot with click animation) via addInitScript
  since headless Chrome doesn't render the system cursor in video
- Add hover-before-click delay to clickByText and canvas clicks
  so viewers can see where the cursor moves before clicking
- Add 'annotate' action: shows a floating label at (x,y) for N ms
  so AI can draw viewer attention to important UI state changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:50:25 +09:00
snomiao
b485d22760 fix: feed QA guide + issue context to agentic reproduce loop
Root cause: runAgenticLoop never read the QA guide — agent saw
"No issue context provided" for issues. Now reads qaGuideFile,
parses structured fields, and injects into system prompt.

Also: fetch issue body via gh issue view in workflow, increase
budget to 120s/30 turns, add focus reminders, smarter stuck
detection (50px grid normalization + action-type frequency),
reject invalid click targets, add loadDefaultWorkflow and
openSettings convenience actions, strategy hints in prompt.

Fix pre-existing typecheck error in eslint.config.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 05:47:04 +00:00
snomiao
2d1088f79e feat: dual badges for PRs — bug reproduction + fix quality
PRs now get two separate badges:
- Bug: REPRODUCED / NOT REPRODUCIBLE / PARTIAL (before branch)
- Fix: APPROVED / MAJOR ISSUES / MINOR ISSUES (after branch)

Issues keep a single badge: FINISHED: REPRODUCED / etc.

Both badge-bug.svg and badge-fix.svg served from the deploy site.
PR comment shows all three: ![badge] ![bug] ![fix]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:39:15 +09:00
snomiao
1fe0f97aa5 fix: badge FINISHED state includes result sub-state
FINISHED is not standalone — always shows result:
- FINISHED: REPRODUCED / NOT REPRODUCIBLE / PARTIAL (issues)
- FINISHED: APPROVED / MAJOR ISSUES / MINOR ISSUES (PRs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:25:53 +09:00
snomiao
178ecc6746 feat: add SVG status badge to QA report site
Badge shows QA pipeline status, deployed at each stage:
- PREPARING (blue) — setting up artifacts
- ANALYZING (orange) — running video review
- Final status with color:
  - Issues: REPRODUCED (red) / NOT REPRODUCIBLE (gray) / PARTIAL (yellow)
  - PRs: APPROVED (green) / MAJOR ISSUES (red) / MINOR ISSUES (yellow)

Badge served as /badge.svg from the same Cloudflare Pages site.
Included in PR comment as ![QA Badge](url/badge.svg).

Also restore @ts-expect-error for import-x plugin type incompatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:04:57 +09:00
GitHub Action
20f878f929 [automated] Apply ESLint and Oxfmt fixes 2026-03-25 04:31:51 +00:00
snomiao
712c386a69 fix: regenerate pnpm-lock.yaml after rebase
The rebase introduced a duplicated mapping key in the lockfile,
causing ERR_PNPM_BROKEN_LOCKFILE in CI.
2026-03-25 04:28:26 +00:00
snomiao
8f6fa738a5 fix: use correct flash model name for agentic loop 2026-03-25 03:19:28 +00:00
snomiao
387862d8b9 feat: agentic screenshot feedback loop + multi-pass recording
Replace single-shot step generation in reproduce mode with an agentic
loop where Gemini sees the screen after each action and decides what
to do next. For multi-bug issues, decompose into sub-issues and run
separate recording passes.

- Extract executeAction() from executeSteps() for reuse
- Add reload and done action types
- Add captureScreenshotForGemini() (JPEG q50, ~50KB)
- Add runAgenticLoop() with sliding window history (3 screenshots)
- Add decomposeIssue() for multi-pass recording (1-3 sub-issues)
- Update workflow to handle numbered session videos (qa-session-1, etc.)
2026-03-25 03:18:01 +00:00
snomiao
ff98eb13e4 feat: add frame-by-frame video controls to QA report player
- Add custom video controls below each video with frame stepping
- Frame back/forward buttons (1 frame at 30fps, 10 frames skip)
- Speed selector: 0.1x, 0.25x, 0.5x (default), 1x, 1.5x, 2x
- Keyboard shortcuts: arrow keys for frame step, space for play/pause
- SMPTE-style timecode display (m:ss.ms)
- Default 0.5x speed since AI operates UI faster than humans
- Videos no longer autoplay (pause on load for inspection)
- Zero external dependencies (pure HTML5 video API)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:18:01 +00:00
snomiao
f4f80d179f fix: cap reproduce video at 5min, skip env setup in Phase 4
- Reproduce video must be max 5 minutes (short, focused demo)
- Phase 4 reuses the environment from Phase 3 (no re-setup)
- Use video-start/video-stop commands (not --save-video flag)
- Start recording right before steps, stop immediately after

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:18:01 +00:00
snomiao
f758e16b72 feat: add reproduce-issue skill with two-video architecture
New Claude agent-driven issue reproduction skill that:
- Phase 1-2: Research issue and set up environment (custom nodes, workflows, settings)
- Phase 3: Record research video while exploring interactively via playwright-cli
- Phase 4: Record clean reproduce video with only the minimal repro steps
- Phase 5: Generate structured reproduction report

Key difference from the old approach: Claude agent explores and adapts
instead of blindly executing a Gemini-generated static plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:18:01 +00:00
snomiao
45e309c5f8 fix: add default workflow node positions to QA prompts
Gemini was right-clicking empty canvas instead of nodes because it
didn't know where the default workflow nodes are positioned. Now the
prompt includes approximate coordinates for all 7 default nodes and
clarifies the difference between node context menu vs canvas menu.

Also fixes TS2352 in page.evaluate by using double-cast through unknown.
2026-03-25 03:18:01 +00:00
snomiao
79df405733 feat: upgrade QA pipeline to Gemini 3.x models
- qa-record.ts, qa-analyze-pr.ts: gemini-2.5-flash/pro → gemini-3.1-pro-preview
- qa-video-review.ts, qa-generate-test.ts: gemini-2.5-flash → gemini-3-flash-preview
- pr-qa.yaml: update hardcoded model reference
- Add docs/qa/models.md with model comparison and rationale
2026-03-25 03:18:01 +00:00
snomiao
27c64e1092 feat: add loadWorkflow and setSetting actions to QA recorder
- Add loadWorkflow action to load workflow JSON from URL
- Add setSetting action to configure ComfyUI settings
- Improve reproduce mode prompt to emphasize establishing prerequisites
  (save workflow first, make it dirty, add needed nodes, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:18:01 +00:00
snomiao
a1307ef35c feat: add clickable issue/PR URLs to QA reports
- Add --target-url CLI option to qa-video-review.ts
- Include target URL in generated markdown reports
- Add clickable issue/PR link in deployed HTML report header
- Workflow passes the target URL automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:17:56 +00:00
snomiao
11f02a2645 fix: resolve pre-existing typecheck errors
- Remove unused @ts-expect-error directives in eslint.config.ts
- Simplify LazyImage prop types from ClassValue to string
- Fix DialogInstance to avoid infinitely deep type instantiation
- Use cn() in DefaultThumbnail for class merging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:17:30 +00:00
snomiao
b3bcc3ff4c fix: improve fillDialog and clickSubmenuItem for litegraph UI components
- fillDialog now tries: PrimeVue dialog → node search box → focused input → keyboard fallback
- clickSubmenuItem now tries: PrimeVue tiered menu → litegraph context menu → role menuitem
- Fixes double-click-to-add-node flow and right-click context menu clicks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:17:30 +00:00
snomiao
49e904918e fix: add step-level error resilience and click timeout in QA recording
- Wrap each step in try/catch so failed steps don't abort the recording
- Add 5s timeout to clickByText to prevent 30s hangs on disabled elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:17:29 +00:00
snomiao
78e5b1e1b3 feat: expand QA action set and improve issue reproduction depth
- Add new canvas actions: rightClick, doubleClick, clickCanvas,
  rightClickCanvas, dragCanvas, scrollCanvas for node graph interactions
- Increase reproduce mode step limit from 3-6 to 8-15 steps
- Add ComfyUI UI context to prompts (canvas layout, node interactions)
- Add anti-hallucination instructions to video review for issue mode
- Improve issue analysis prompt with detailed action descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:17:29 +00:00
snomiao
81e3dc72f8 fix: use pulls API instead of gh pr view for PR/issue detection
gh pr view can't distinguish PRs from issues — it succeeds for both.
Use the REST API endpoint repos/{owner}/{repo}/pulls/{number} which
returns 404 for issues.
2026-03-25 03:17:29 +00:00
snomiao
936cf83337 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-25 03:17:29 +00:00
snomiao
1d880bf493 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-25 03:17:29 +00:00
snomiao
da3e6cb4cf 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-25 03:17:29 +00:00
snomiao
a39e3054cf 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-25 03:17:29 +00:00
snomiao
5a6178e924 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-25 03:17:29 +00:00
snomiao
a11e8a67f8 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-25 03:17:29 +00:00
snomiao
6a836b7c25 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-25 03:17:29 +00:00
snomiao
f91f94f71a 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-25 03:17:29 +00:00
snomiao
7502528733 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-25 03:17:29 +00:00
snomiao
0656091959 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-25 03:17:29 +00:00
snomiao
6515170d08 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-25 03:17:29 +00:00
snomiao
25cbe56a34 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-25 03:17:29 +00:00
snomiao
120a531ef9 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-25 03:17:29 +00:00
snomiao
389f6ca6b8 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-25 03:17:29 +00:00
snomiao
6298fc3a58 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-25 03:17:29 +00:00
snomiao
83702c2e87 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-25 03:17:29 +00:00
snomiao
c5d207fa9a 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-25 03:17:29 +00:00
snomiao
ab6dff02c9 feat: autoplay and loop videos on QA dashboard 2026-03-25 03:17:29 +00:00
snomiao
7396d39a6a 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-25 03:17:29 +00:00
snomiao
99e6681237 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-25 03:17:29 +00:00
snomiao
ae29224874 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-25 03:17:29 +00:00
snomiao
47e5c39ac9 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-25 03:17:29 +00:00
snomiao
28f530d53a 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-25 03:17:28 +00:00
snomiao
282754743d fix: temporarily disable concurrency group to unstick QA runs 2026-03-25 03:17:28 +00:00
snomiao
0a91ec09e8 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-25 03:17:28 +00:00
snomiao
25fd1b2700 fix: use unique concurrency group to unstick QA runs 2026-03-25 03:17:28 +00:00
snomiao
b9d5ff0f8d ci: trigger QA run 2026-03-25 03:17:28 +00:00
snomiao
746d465912 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-25 03:17:28 +00:00
snomiao
e314a18b90 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-25 03:17:28 +00:00
snomiao
78fb9ef27f 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-25 03:17:28 +00:00
snomiao
41999c2e0f 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-25 03:17:28 +00:00
snomiao
5c243d16be 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-25 03:17:28 +00:00
snomiao
774dcd823e 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-25 03:17:28 +00:00
snomiao
b500f826fc 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-25 03:17:28 +00:00
snomiao
1cd9e171c6 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-25 03:17:28 +00:00
snomiao
5c0bef9b72 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-25 03:17:28 +00:00
snomiao
94e1388495 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-25 03:17:28 +00:00
snomiao
0268e8f977 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-25 03:17:28 +00:00
snomiao
4f1df7c7ce 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-25 03:17:28 +00:00
snomiao
3b2fdc786a 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-25 03:17:28 +00:00
snomiao
4d1ad4dcf0 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-25 03:17:28 +00:00
snomiao
7ba1aaed53 fix: run report job on workflow_dispatch events
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:17:28 +00:00
snomiao
09a3c10d50 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-25 03:17:28 +00:00
snomiao
2f30fbe060 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-25 03:17:27 +00:00
snomiao
85adbe4878 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-25 03:17:27 +00:00
snomiao
0c707b5deb 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-25 03:17:27 +00:00
snomiao
4f98518c22 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-25 03:17:27 +00:00
snomiao
ca394b9ff7 fix: improve focused QA prompt to test PR-specific behavior, not random walk 2026-03-25 03:17:27 +00:00
snomiao
51878db99d fix: re-add push trigger for sno-skills and sno-qa-* branches 2026-03-25 03:17:27 +00:00
snomiao
d0534003e7 fix: also install ffprobe for GPT video review frame extraction 2026-03-25 03:17:27 +00:00
snomiao
3d0cd72465 fix: use sudo for ffmpeg static binary extraction to /usr/local/bin 2026-03-25 03:17:27 +00:00
snomiao
82849df891 fix: use static ffmpeg binary instead of apt-get (avoids dpkg lock hang) 2026-03-25 03:17:27 +00:00
snomiao
1c62c0edc3 fix: add ffmpeg install back (not pre-installed on GH runners) 2026-03-25 03:17:27 +00:00
snomiao
6ea2ce755d fix: normalize flat artifact download into expected subdirectory 2026-03-25 03:17:27 +00:00
snomiao
8fc4480ee2 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-25 03:17:27 +00:00
snomiao
3515a478fd fix: add debug output to video convert step 2026-03-25 03:17:27 +00:00
snomiao
11432992d3 fix: use danger-full-access sandbox for codex on GH Actions 2026-03-25 03:17:27 +00:00
snomiao
e619b0143a fix: use correct codex model name gpt-5.4-mini 2026-03-25 03:17:27 +00:00
snomiao
7d4a008f29 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-25 03:17:27 +00:00
snomiao
a3e65140a9 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-25 03:17:27 +00:00
snomiao
f55ab36dd7 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-25 03:17:27 +00:00
snomiao
698a894b42 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-25 03:17:27 +00:00
snomiao
fbd7f404ef 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-25 03:17:27 +00:00
snomiao
d633ce19a7 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-25 03:17:27 +00:00
snomiao
9a7b5f88a0 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-25 03:17:27 +00:00
snomiao
be261a8a86 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-25 03:17:27 +00:00
snomiao
b2c31e785f 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-25 03:17:27 +00:00
snomiao
475f9ae5f0 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-25 03:17:27 +00:00
GitHub Action
c329022e15 [automated] Apply ESLint and Oxfmt fixes 2026-03-25 03:17:27 +00:00
snomiao
63d0df9ff0 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-25 03:17:27 +00:00
snomiao
c1593054fb 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-25 03:17:26 +00:00
552 changed files with 13298 additions and 40833 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,278 @@
---
name: reproduce-issue
description: 'Reproduce a GitHub issue by researching prerequisites, setting up the environment (custom nodes, workflows, settings), and interactively exploring ComfyUI via playwright-cli until the bug is confirmed. Then records a clean demo video.'
---
# Issue Reproduction Skill
Reproduce a reported GitHub issue against a running ComfyUI instance. This skill uses an interactive, agent-driven approach — not a static script. You will research, explore, retry, and adapt until the bug is reproduced, then record a clean demo.
## Architecture
Two videos are produced:
1. **Research video** — the full exploration session: installing deps, trying things, failing, retrying, figuring out the bug. Valuable for debugging context.
2. **Reproduce video** — a clean, minimal recording of just the reproduction steps. This is the demo you'd attach to the issue.
```
Phase 1: Research → Read issue, understand prerequisites
Phase 2: Environment → Install custom nodes, load workflows, configure settings
Phase 3: Explore → [VIDEO 1: research] Interactively try to reproduce (retries OK)
Phase 4: Record → [VIDEO 2: reproduce] Clean recording of just the minimal repro steps
Phase 5: Report → Generate a structured reproduction report
```
## Prerequisites
- ComfyUI server running (ask user for URL, default: `http://127.0.0.1:8188`)
- `playwright-cli` installed: `npm install -g @playwright/cli@latest`
- `gh` CLI (authenticated, for reading issues)
- ComfyUI backend with Python environment (for installing custom nodes)
## Phase 1: Research the Issue
1. Fetch the issue details:
```bash
gh issue view <number> --repo Comfy-Org/ComfyUI_frontend --json title,body,comments
```
2. Extract from the issue body:
- **Reproduction steps** (the exact sequence)
- **Prerequisites**: specific workflows, custom nodes, settings, models
- **Environment**: OS, browser, ComfyUI version
- **Media**: screenshots or videos showing the bug
3. Search the codebase for related code:
- Find the feature/component mentioned in the issue
- Understand how it works currently
- Identify what state the UI needs to be in
## Phase 2: Environment Setup
Set up everything the issue requires BEFORE attempting reproduction.
### Custom Nodes
If the issue mentions custom nodes:
```bash
# Find the custom node repo
# Clone into ComfyUI's custom_nodes directory
cd <comfyui_path>/custom_nodes
git clone <custom_node_repo_url>
# Install dependencies if needed
cd <custom_node_name>
pip install -r requirements.txt 2>/dev/null || true
# Restart ComfyUI server to load the new nodes
```
### Workflows
If the issue references a specific workflow:
```bash
# Download workflow JSON if a URL is provided
curl -L "<workflow_url>" -o /tmp/test-workflow.json
# Load it via the API
curl -X POST http://127.0.0.1:8188/api/workflow \
-H "Content-Type: application/json" \
-d @/tmp/test-workflow.json
```
Or load via playwright-cli:
```bash
playwright-cli goto "http://127.0.0.1:8188"
# Drag-and-drop or use File > Open to load the workflow
```
### Settings
If the issue requires specific settings:
```bash
# Use playwright-cli to open settings and change them
playwright-cli press "Control+,"
playwright-cli snapshot
# Find and modify the relevant setting
```
## Phase 3: Interactive Exploration — Research Video
Start recording the **research video** (Video 1). This captures the full exploration — mistakes, retries, dead ends — all valuable context.
```bash
# Open browser and start video recording
playwright-cli open "http://127.0.0.1:8188"
playwright-cli video-start
# Take a snapshot to see current state
playwright-cli snapshot
# Interact based on what you see
playwright-cli click <ref>
playwright-cli fill <ref> "text"
playwright-cli press "Control+s"
# Check results
playwright-cli snapshot
playwright-cli screenshot --filename=/tmp/qa/research-step-1.png
```
### Key Principles
- **Observe before acting**: Always `snapshot` before interacting
- **Retry and adapt**: If a step fails, try a different approach
- **Document what works**: Keep notes on which steps trigger the bug
- **Don't give up**: Try multiple approaches if the first doesn't work
- **Establish prerequisites**: Many bugs require specific UI state:
- Save a workflow first (File > Save)
- Make changes to dirty the workflow
- Open multiple tabs
- Add specific node types
- Change settings
- Resize the window
### Common ComfyUI Interactions via playwright-cli
| Action | Command |
| ------------------- | -------------------------------------------------------------- |
| Open hamburger menu | `playwright-cli click` on the C logo button |
| Navigate menu | `playwright-cli hover <ref>` then `playwright-cli click <ref>` |
| Add node | Double-click canvas → type node name → select from results |
| Connect nodes | Drag from output slot to input slot |
| Save workflow | `playwright-cli press "Control+s"` |
| Save As | Menu > File > Save As |
| Select node | Click on the node |
| Delete node | Select → `playwright-cli press "Delete"` |
| Right-click menu | `playwright-cli click <ref> --button right` |
| Keyboard shortcut | `playwright-cli press "Control+z"` |
## Phase 4: Record Clean Demo — Reproduce Video (max 5 minutes)
Once the bug is confirmed, **stop the research video** and **close the research browser**:
```bash
playwright-cli video-stop
playwright-cli close
```
Now start a **fresh browser session** for the clean reproduce video (Video 2).
**IMPORTANT constraints:**
- **Max 5 minutes** — the reproduce video must be short and focused
- **No environment setup** — server, user, custom nodes are already set up from Phase 3. Just log in and go.
- **No exploration** — you already know the exact steps. Execute them quickly and precisely.
- **Start video recording immediately**, execute steps, stop. Don't leave the recording running while thinking.
1. **Open browser and start recording**:
```bash
playwright-cli open "http://127.0.0.1:8188"
playwright-cli video-start
```
2. **Execute only the minimal reproduction steps** — no exploration, no mistakes. Just the clean sequence that demonstrates the bug. You already know exactly what works from Phase 3.
3. **Take key screenshots** at critical moments:
```bash
playwright-cli screenshot --filename=/tmp/qa/before-bug.png
# ... trigger the bug ...
playwright-cli screenshot --filename=/tmp/qa/bug-visible.png
```
4. **Stop recording and close** immediately after the bug is demonstrated:
```bash
playwright-cli video-stop
playwright-cli close
```
## Phase 5: Generate Report
Create a reproduction report at `tmp/qa/reproduce-report.md`:
```markdown
# Issue Reproduction Report
- **Issue**: <issue_url>
- **Title**: <issue_title>
- **Date**: <today>
- **Status**: Reproduced / Not Reproduced / Partially Reproduced
## Environment
- ComfyUI Server: <url>
- OS: <os>
- Custom Nodes Installed: <list or "none">
- Settings Changed: <list or "none">
## Prerequisites
List everything that had to be set up before the bug could be triggered:
1. ...
2. ...
## Reproduction Steps
Minimal steps to reproduce (the clean sequence):
1. ...
2. ...
3. ...
## Expected Behavior
<from the issue>
## Actual Behavior
<what actually happened>
## Evidence
- Research video: `research-video/video.webm` (full exploration session)
- Reproduce video: `reproduce-video/video.webm` (clean minimal repro)
- Screenshots: `before-bug.png`, `bug-visible.png`
## Root Cause Analysis (if identified)
<code pointers, hypothesis about what's going wrong>
## Notes
<any additional observations, workarounds discovered, related issues>
```
## Handling Failures
If the bug **cannot be reproduced**:
1. Document what you tried and why it didn't work
2. Check if the issue was already fixed (search git log for related commits)
3. Check if it's environment-specific (OS, browser, specific version)
4. Set report status to "Not Reproduced" with detailed notes
5. The report is still valuable — it saves others from repeating the same investigation
## CI Integration
In CI, this skill runs as a Claude Code agent with:
- `ANTHROPIC_API_KEY` for Claude
- `GEMINI_API_KEY` for initial issue analysis (optional)
- ComfyUI server pre-started in the container
- `playwright-cli` pre-installed
The CI workflow:
1. Gemini generates a reproduce guide (markdown) from the issue
2. Claude agent receives the guide and runs this skill
3. Claude explores interactively, installs dependencies, retries
4. Claude records a clean demo once reproduced
5. Video and report are uploaded as artifacts

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

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

@@ -13,6 +13,8 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -17,6 +17,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -22,6 +22,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -21,6 +21,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -20,6 +20,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -21,6 +21,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -74,6 +76,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

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

View File

@@ -30,6 +30,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

993
.github/workflows/pr-qa.yaml vendored Normal file
View File

@@ -0,0 +1,993 @@
# Automated QA of ComfyUI frontend using Playwright video recordings + Gemini review.
# Architecture:
# resolve-matrix → qa-before (main) ─┐
# → qa-after (PR) ─┴→ report
#
# Before/after run in PARALLEL on separate runners for clean isolation.
# Two modes:
# Focused (qa-changes label): Linux-only, before/after comparison
# Full (qa-full label): 3-OS matrix, after-only
name: 'PR: QA'
on:
# TODO: remove push trigger before merge
push:
branches: [sno-skills, sno-qa-*]
pull_request:
types: [labeled]
branches: [main]
workflow_dispatch:
inputs:
mode:
description: 'QA mode'
type: choice
options: [focused, full]
default: focused
# TODO: restore concurrency group before merge (disabled for parallel sno-qa-* testing)
# concurrency:
# group: ${{ github.workflow }}-${{ github.ref }}
# cancel-in-progress: true
jobs:
resolve-matrix:
runs-on: ubuntu-latest
outputs:
os: ${{ steps.set.outputs.os }}
mode: ${{ steps.set.outputs.mode }}
skip: ${{ steps.set.outputs.skip }}
number: ${{ steps.resolve-number.outputs.number }}
target_type: ${{ steps.resolve-number.outputs.target_type }}
before_sha: ${{ steps.resolve-refs.outputs.before_sha }}
after_sha: ${{ steps.resolve-refs.outputs.after_sha }}
steps:
- name: Determine QA mode
id: set
env:
LABEL: ${{ github.event.label.name }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_NAME: ${{ github.event_name }}
INPUT_MODE: ${{ inputs.mode }}
run: |
FULL=false
# Only run on label events if it's one of our labels
if [ "$EVENT_ACTION" = "labeled" ] && \
[ "$LABEL" != "qa-changes" ] && [ "$LABEL" != "qa-full" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
# Full QA triggers
if [ "$EVENT_NAME" = "workflow_dispatch" ] && \
[ "$INPUT_MODE" = "full" ]; then
FULL=true
fi
if [ "$LABEL" = "qa-full" ]; then
FULL=true
fi
if [ "$FULL" = "true" ]; then
echo 'os=["ubuntu-latest","macos-latest","windows-latest"]' >> "$GITHUB_OUTPUT"
echo "mode=full" >> "$GITHUB_OUTPUT"
else
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
echo "mode=focused" >> "$GITHUB_OUTPUT"
fi
echo "Mode: $([ "$FULL" = "true" ] && echo full || echo focused)"
- name: Resolve target number and type
id: resolve-number
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUM: ${{ github.event.pull_request.number }}
BRANCH: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUM" ]; then
NUM="$PR_NUM"
else
NUM=$(gh pr list --repo "$REPO" \
--head "$BRANCH" --state open \
--json number --jq '.[0].number // empty')
if [ -z "$NUM" ]; then
NUM=$(echo "$BRANCH" | sed -n 's/^sno-qa-\([0-9]\+\)$/\1/p')
fi
fi
echo "number=${NUM}" >> "$GITHUB_OUTPUT"
if [ -n "$NUM" ]; then
# Use the API to check if it's a PR (gh pr view can't distinguish)
if gh api "repos/${REPO}/pulls/${NUM}" --jq '.number' >/dev/null 2>&1; then
echo "target_type=pr" >> "$GITHUB_OUTPUT"
echo "Target: PR #$NUM"
else
echo "target_type=issue" >> "$GITHUB_OUTPUT"
echo "Target: Issue #$NUM"
fi
fi
- name: Resolve commit SHAs for immutable references
id: resolve-refs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUM: ${{ steps.resolve-number.outputs.number }}
TARGET_TYPE: ${{ steps.resolve-number.outputs.target_type }}
REPO: ${{ github.repository }}
run: |
MAIN_SHA=$(gh api "repos/${REPO}/git/ref/heads/main" --jq '.object.sha')
echo "before_sha=${MAIN_SHA}" >> "$GITHUB_OUTPUT"
echo "Main: ${MAIN_SHA:0:7}"
if [ "$TARGET_TYPE" = "pr" ] && [ -n "$NUM" ]; then
PR_SHA=$(gh pr view "$NUM" --repo "$REPO" --json headRefOid --jq '.headRefOid')
echo "after_sha=${PR_SHA}" >> "$GITHUB_OUTPUT"
echo "PR #${NUM}: ${PR_SHA:0:7}"
fi
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Analyze PR — deep analysis via Gemini Pro to generate QA guides
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
analyze-pr:
needs: [resolve-matrix]
if: needs.resolve-matrix.outputs.skip != 'true' && needs.resolve-matrix.outputs.mode == 'focused'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.head_ref || github.ref }}
- name: Setup frontend (scripts only)
uses: ./.github/actions/setup-frontend
with:
include_build_step: false
- name: Run analysis
if: needs.resolve-matrix.outputs.number
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p qa-guides
pnpm exec tsx scripts/qa-analyze-pr.ts \
--pr-number "${{ needs.resolve-matrix.outputs.number }}" \
--repo "${{ github.repository }}" \
--output-dir qa-guides \
--type "${{ needs.resolve-matrix.outputs.target_type }}"
- name: Upload QA guides
if: always() && needs.resolve-matrix.outputs.number
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
with:
name: qa-guides-${{ github.run_id }}
path: qa-guides/qa-guide-*.json
retention-days: 14
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# BEFORE recording — main branch frontend on its own runner
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
qa-before:
needs: [resolve-matrix, analyze-pr]
if: >-
always() &&
needs.resolve-matrix.outputs.skip != 'true' &&
needs.resolve-matrix.outputs.mode == 'focused' &&
needs.resolve-matrix.result == 'success'
strategy:
fail-fast: false
matrix:
os: ${{ fromJson(needs.resolve-matrix.outputs.os) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Set QA artifacts path
shell: bash
run: echo "QA_ARTIFACTS=$RUNNER_TEMP/qa-artifacts" >> "$GITHUB_ENV"
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
# Install pnpm/node for PR checkout (needed for tsx scripts later)
- name: Setup frontend (scripts only)
uses: ./.github/actions/setup-frontend
with:
include_build_step: false
- name: Build main branch frontend via worktree
shell: bash
run: |
git worktree add ../main-build origin/main
cd ../main-build
pnpm install --frozen-lockfile || pnpm install
pnpm exec vite build
cd "$GITHUB_WORKSPACE"
mv ../main-build/dist dist
git worktree remove ../main-build --force
echo "Built main branch frontend"
ls -la dist/index.html
- name: Setup ComfyUI server (no launch)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Install Playwright browser
shell: bash
run: |
npx playwright install chromium
mkdir -p "$QA_ARTIFACTS"
- name: Get PR diff
if: needs.resolve-matrix.outputs.target_type == 'pr'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number || '' }} \
--repo ${{ github.repository }} > "${{ runner.temp }}/pr-diff.txt" 2>/dev/null || \
git diff origin/main...HEAD > "${{ runner.temp }}/pr-diff.txt"
echo "Changed files:"
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
sed 's|diff --git a/||;s| b/.*||' | sort -u
- name: Get issue body (for reproduce mode)
if: needs.resolve-matrix.outputs.target_type == 'issue' && needs.resolve-matrix.outputs.number
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue view ${{ needs.resolve-matrix.outputs.number }} \
--repo ${{ github.repository }} \
--json title,body --jq '.title + "\n\n" + .body' \
> "${{ runner.temp }}/issue-body.txt"
echo "Issue body saved ($(wc -c < "${{ runner.temp }}/issue-body.txt") bytes)"
- name: Download QA guide
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: qa-guides-${{ github.run_id }}
path: qa-guides
- name: Start server with main branch frontend
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../dist &
echo $! > /tmp/comfyui-server.pid
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready (main)" && exit 0
sleep 2
done
echo "::error::Server timeout (main)"; exit 1
- name: Pre-seed settings
shell: bash
run: |
curl -sf -X POST http://127.0.0.1:8188/api/users \
-H 'Content-Type: application/json' \
-d '{"username":"qa-ci"}' || echo "User creation failed (may already exist)"
curl -sf -X POST http://127.0.0.1:8188/api/devtools/set_settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || \
curl -sf -X POST http://127.0.0.1:8188/api/settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || echo "Settings pre-seed skipped"
- name: Run QA recording
shell: bash
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
TARGET_TYPE: ${{ needs.resolve-matrix.outputs.target_type }}
run: |
MODE="before"
if [ "$TARGET_TYPE" = "issue" ]; then
MODE="reproduce"
fi
QA_GUIDE_FLAG=""
if [ -f qa-guides/qa-guide-before.json ]; then
echo "Using QA guide for $MODE recording"
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-before.json"
fi
DIFF_FLAG=""
if [ -f "${{ runner.temp }}/pr-diff.txt" ]; then
DIFF_FLAG="--diff ${{ runner.temp }}/pr-diff.txt"
elif [ -f "${{ runner.temp }}/issue-body.txt" ]; then
DIFF_FLAG="--diff ${{ runner.temp }}/issue-body.txt"
fi
pnpm exec tsx scripts/qa-record.ts \
--mode "$MODE" \
$DIFF_FLAG \
--output-dir "$QA_ARTIFACTS" \
--url http://127.0.0.1:8188 \
--test-plan .claude/skills/comfy-qa/SKILL.md \
$QA_GUIDE_FLAG
- name: Collect artifacts
if: always()
shell: bash
run: |
kill "$(cat /tmp/comfyui-server.pid)" 2>/dev/null || true
echo "=== QA BEFORE artifacts ==="
ls -la "$QA_ARTIFACTS/" 2>/dev/null | head -30
- name: Upload QA artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
with:
name: qa-before-${{ runner.os }}-${{ github.run_id }}
path: ${{ env.QA_ARTIFACTS }}/
retention-days: 14
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AFTER recording — PR branch frontend on its own runner
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
qa-after:
needs: [resolve-matrix, analyze-pr]
if: >-
always() &&
needs.resolve-matrix.outputs.skip != 'true' &&
needs.resolve-matrix.result == 'success' &&
needs.resolve-matrix.outputs.target_type == 'pr'
strategy:
fail-fast: false
matrix:
os: ${{ fromJson(needs.resolve-matrix.outputs.os) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Set QA artifacts path
shell: bash
run: echo "QA_ARTIFACTS=$RUNNER_TEMP/qa-artifacts" >> "$GITHUB_ENV"
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
# When triggered via sno-qa-* push, the checkout above gets sno-skills
# (the scripts branch), not the actual PR. Fetch the PR ref and check it out.
- name: Checkout PR head for sno-qa-* triggers
if: >-
!github.head_ref &&
needs.resolve-matrix.outputs.target_type == 'pr' &&
needs.resolve-matrix.outputs.number
shell: bash
env:
PR_NUM: ${{ needs.resolve-matrix.outputs.number }}
run: |
git fetch origin "refs/pull/${PR_NUM}/head"
git checkout FETCH_HEAD
echo "Checked out PR #${PR_NUM} at $(git rev-parse --short HEAD)"
- name: Setup frontend (PR branch)
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI server (no launch)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Install Playwright browser
shell: bash
run: |
npx playwright install chromium
mkdir -p "$QA_ARTIFACTS"
- name: Get PR diff
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number || '' }} \
--repo ${{ github.repository }} > "${{ runner.temp }}/pr-diff.txt" 2>/dev/null || \
git diff origin/main...HEAD > "${{ runner.temp }}/pr-diff.txt"
echo "Changed files:"
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
sed 's|diff --git a/||;s| b/.*||' | sort -u
- name: Download QA guide
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: qa-guides-${{ github.run_id }}
path: qa-guides
- name: Start server with PR branch frontend
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../dist &
echo $! > /tmp/comfyui-server.pid
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready (PR)" && exit 0
sleep 2
done
echo "::error::Server timeout (PR)"; exit 1
- name: Pre-seed settings
shell: bash
run: |
curl -sf -X POST http://127.0.0.1:8188/api/users \
-H 'Content-Type: application/json' \
-d '{"username":"qa-ci"}' || echo "User creation failed (may already exist)"
curl -sf -X POST http://127.0.0.1:8188/api/devtools/set_settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || \
curl -sf -X POST http://127.0.0.1:8188/api/settings \
-H 'Content-Type: application/json' \
-d '{"Comfy.TutorialCompleted":true}' || echo "Settings pre-seed skipped"
- name: Run AFTER QA recording
shell: bash
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
QA_GUIDE_FLAG=""
if [ -f qa-guides/qa-guide-after.json ]; then
echo "Using QA guide for after recording"
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-after.json"
fi
pnpm exec tsx scripts/qa-record.ts \
--mode after \
--diff "${{ runner.temp }}/pr-diff.txt" \
--output-dir "$QA_ARTIFACTS" \
--url http://127.0.0.1:8188 \
--test-plan .claude/skills/comfy-qa/SKILL.md \
$QA_GUIDE_FLAG
- name: Collect artifacts
if: always()
shell: bash
run: |
kill "$(cat /tmp/comfyui-server.pid)" 2>/dev/null || true
echo "=== QA AFTER artifacts ==="
ls -la "$QA_ARTIFACTS/" 2>/dev/null | head -30
- name: Upload QA artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
with:
name: qa-after-${{ runner.os }}-${{ github.run_id }}
path: ${{ env.QA_ARTIFACTS }}/
retention-days: 14
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Report — merges artifacts, runs Gemini video review, deploys
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
report:
needs: [resolve-matrix, analyze-pr, qa-before, qa-after]
if: >-
always() &&
(needs.qa-after.result == 'success' || needs.qa-before.result == 'success') &&
(github.event.pull_request.number || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Configure git identity
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Setup badge deploy function
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
RAW_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
npm install -g wrangler@4.74.0 >/dev/null 2>&1
BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-28)
echo "QA_BRANCH=$BRANCH" >> "$GITHUB_ENV"
# Create badge generator script
cat > /tmp/gen-badge.sh <<'BADGESCRIPT'
#!/bin/bash
# Usage: gen-badge.sh <status> <color> <output-path> [label]
STATUS="$1" COLOR="$2" OUT="$3"
LABEL="${4:-QA Bot}"
LABEL_W=$(( ${#LABEL} * 7 + 12 ))
STATUS_W=$(( ${#STATUS} * 7 + 12 ))
TOTAL_W=$(( LABEL_W + STATUS_W ))
cat > "$OUT" <<SVGEOF
<svg xmlns="http://www.w3.org/2000/svg" width="${TOTAL_W}" height="20" role="img" aria-label="${LABEL}: ${STATUS}">
<title>${LABEL}: ${STATUS}</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="${TOTAL_W}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="${LABEL_W}" height="20" fill="#555"/>
<rect x="${LABEL_W}" width="${STATUS_W}" height="20" fill="${COLOR}"/>
<rect width="${TOTAL_W}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$(( LABEL_W / 2 ))" y="15" fill="#010101" fill-opacity=".3">${LABEL}</text>
<text x="$(( LABEL_W / 2 ))" y="14">${LABEL}</text>
<text aria-hidden="true" x="$(( LABEL_W + STATUS_W / 2 ))" y="15" fill="#010101" fill-opacity=".3">${STATUS}</text>
<text x="$(( LABEL_W + STATUS_W / 2 ))" y="14">${STATUS}</text>
</g>
</svg>
SVGEOF
BADGESCRIPT
chmod +x /tmp/gen-badge.sh
# Create badge deploy script
cat > /tmp/deploy-badge.sh <<DEPLOYSCRIPT
#!/bin/bash
# Usage: deploy-badge.sh <status> [color]
STATUS="\$1"
COLOR="\${2:-#555}"
DIR=\$(mktemp -d)
/tmp/gen-badge.sh "\$STATUS" "\$COLOR" "\$DIR/badge.svg"
# Also create a minimal redirect page
echo '<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=badge.svg"></head></html>' > "\$DIR/index.html"
wrangler pages deploy "\$DIR" \
--project-name="comfy-qa" \
--branch="${BRANCH}" 2>&1 | tail -3
rm -rf "\$DIR"
echo "Badge deployed: ${STATUS}"
DEPLOYSCRIPT
chmod +x /tmp/deploy-badge.sh
- name: Setup dual badge generator
run: |
cat > /tmp/gen-badge-dual.sh <<'DUALBADGE'
#!/bin/bash
# Usage: gen-badge-dual.sh <repro> <repro_color> <fix> <fix_color> <output-path> [label]
BUG="$1" BUG_C="$2" FIX="Fix: $3" FIX_C="$4" OUT="$5"
LABEL="${6:-QA Bot}"
LW=$(( ${#LABEL} * 7 + 12 ))
BW=$(( ${#BUG} * 7 + 12 ))
FW=$(( ${#FIX} * 7 + 12 ))
TW=$(( LW + BW + FW ))
cat > "$OUT" <<SVGEOF
<svg xmlns="http://www.w3.org/2000/svg" width="${TW}" height="20" role="img" aria-label="${LABEL}: ${BUG} | ${FIX}">
<title>${LABEL}: ${BUG} | ${FIX}</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="${TW}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="${LW}" height="20" fill="#555"/>
<rect x="${LW}" width="${BW}" height="20" fill="${BUG_C}"/>
<rect x="$(( LW + BW ))" width="${FW}" height="20" fill="${FIX_C}"/>
<rect width="${TW}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$(( LW / 2 ))" y="15" fill="#010101" fill-opacity=".3">${LABEL}</text>
<text x="$(( LW / 2 ))" y="14">${LABEL}</text>
<text aria-hidden="true" x="$(( LW + BW / 2 ))" y="15" fill="#010101" fill-opacity=".3">${BUG}</text>
<text x="$(( LW + BW / 2 ))" y="14">${BUG}</text>
<text aria-hidden="true" x="$(( LW + BW + FW / 2 ))" y="15" fill="#010101" fill-opacity=".3">${FIX}</text>
<text x="$(( LW + BW + FW / 2 ))" y="14">${FIX}</text>
</g>
</svg>
SVGEOF
DUALBADGE
chmod +x /tmp/gen-badge-dual.sh
- name: Deploy badge — PREPARING
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: /tmp/deploy-badge.sh "PREPARING" "#2196f3"
- name: Resolve target number and type
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUM: ${{ github.event.pull_request.number }}
BRANCH: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUM" ]; then
NUM="$PR_NUM"
else
NUM=$(gh pr list --repo "$REPO" \
--head "$BRANCH" --state open \
--json number --jq '.[0].number // empty')
if [ -z "$NUM" ]; then
NUM=$(echo "$BRANCH" | sed -n 's/^sno-qa-\([0-9]\+\)$/\1/p')
fi
fi
echo "number=${NUM}" >> "$GITHUB_OUTPUT"
if [ -n "$NUM" ]; then
if gh api "repos/${REPO}/pulls/${NUM}" --jq '.number' >/dev/null 2>&1; then
echo "target_type=pr" >> "$GITHUB_OUTPUT"
else
echo "target_type=issue" >> "$GITHUB_OUTPUT"
fi
fi
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download BEFORE artifacts
if: needs.qa-before.result == 'success'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: qa-artifacts/before
pattern: qa-before-*
- name: Download AFTER artifacts
if: needs.qa-after.result == 'success'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: qa-artifacts/after
pattern: qa-after-*
- name: Merge artifacts into per-OS report directories
run: |
echo "=== Downloaded BEFORE artifacts ==="
find qa-artifacts/before -type f 2>/dev/null | head -20
echo "=== Downloaded AFTER artifacts ==="
find qa-artifacts/after -type f 2>/dev/null | head -20
for os in Linux macOS Windows; do
REPORT_DIR="qa-artifacts/qa-report-${os}-${{ github.run_id }}"
HAS_FILES=false
# Check for before files (flat or in subdirectory)
if [ -d "qa-artifacts/before" ] && find qa-artifacts/before -name '*.webm' -o -name '*.png' 2>/dev/null | grep -q .; then
HAS_FILES=true
fi
# Check for after files
if [ -d "qa-artifacts/after" ] && find qa-artifacts/after -name '*.webm' -o -name '*.png' 2>/dev/null | grep -q .; then
HAS_FILES=true
fi
if [ "$HAS_FILES" = true ]; then
mkdir -p "$REPORT_DIR"
# Copy all before files (handles both flat and nested layouts)
find qa-artifacts/before -type f 2>/dev/null | while read f; do
cp "$f" "$REPORT_DIR/" 2>/dev/null || true
done
# Copy all after files (overwrites duplicates with after versions)
find qa-artifacts/after -type f 2>/dev/null | while read f; do
cp "$f" "$REPORT_DIR/" 2>/dev/null || true
done
echo "Merged $os artifacts into $REPORT_DIR"
ls -la "$REPORT_DIR/" | head -20
break # Only create one report dir (multi-OS not yet supported in parallel mode)
fi
done
- name: Install ffmpeg
run: |
if command -v ffmpeg &>/dev/null; then
echo "ffmpeg already installed"
else
echo "Downloading static ffmpeg..."
TMP=$(mktemp -d)
curl -sL "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" | tar xJ -C "$TMP"
sudo cp "$TMP"/ffmpeg-*/ffmpeg "$TMP"/ffmpeg-*/ffprobe /usr/local/bin/
rm -rf "$TMP"
fi
ffmpeg -version | head -1
ffprobe -version | head -1
- name: Convert videos to mp4
run: |
convert_video() {
local WEBM="$1" MP4="$2"
echo "Converting $WEBM ($(du -h "$WEBM" | cut -f1)) to $MP4"
ffmpeg -y -i "$WEBM" \
-c:v libx264 -preset ultrafast -crf 23 -pix_fmt yuv420p \
-movflags +faststart -g 60 \
"$MP4" 2>&1 | tail -5 \
|| echo "ffmpeg conversion failed for $WEBM (non-fatal)"
[ -f "$MP4" ] && echo "Created: $MP4 ($(du -h "$MP4" | cut -f1))"
}
for dir in qa-artifacts/qa-report-*; do
[ -d "$dir" ] || continue
# Convert known video names (single + multi-pass + before)
for name in qa-session qa-session-1 qa-session-2 qa-session-3 qa-before-session; do
if [ -f "$dir/${name}.webm" ] && [ -s "$dir/${name}.webm" ]; then
convert_video "$dir/${name}.webm" "$dir/${name}.mp4"
fi
done
# Fallback: find any non-empty webm not yet converted
if [ ! -f "$dir/qa-session.mp4" ] && [ ! -f "$dir/qa-session-1.mp4" ]; then
WEBM=$(find "$dir" -name '*.webm' -type f -size +0c | head -1)
[ -n "$WEBM" ] && convert_video "$WEBM" "$dir/qa-session.mp4"
fi
done
- name: Deploy badge — ANALYZING
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: /tmp/deploy-badge.sh "ANALYZING" "#ff9800"
- name: Build context for video review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_NUM: ${{ steps.pr.outputs.number }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TARGET_NUM" ]; then
echo "No target number available, skipping context"
exit 0
fi
if [ "$TARGET_TYPE" = "issue" ]; then
{
echo "### Issue #${TARGET_NUM}"
gh issue view "$TARGET_NUM" --repo "$REPO" \
--json title,body --jq '"Title: \(.title)\n\nDescription:\n\(.body)"' 2>/dev/null || true
echo ""
echo "### Comments"
gh api "repos/${REPO}/issues/${TARGET_NUM}/comments" \
--jq '.[].body' 2>/dev/null | head -200 || true
echo ""
echo "This video attempts to reproduce a reported bug on the main branch."
} > pr-context.txt
echo "Issue context saved ($(wc -l < pr-context.txt) lines)"
else
{
echo "### PR #${TARGET_NUM}"
gh pr view "$TARGET_NUM" --repo "$REPO" \
--json title,body --jq '"Title: \(.title)\n\nDescription:\n\(.body)"' 2>/dev/null || true
echo ""
echo "### Changed files"
gh pr diff "$TARGET_NUM" --repo "$REPO" 2>/dev/null \
| grep '^diff --git' | sed 's|diff --git a/||;s| b/.*||' | sort -u || true
echo ""
echo "### Diff (truncated to 300 lines)"
gh pr diff "$TARGET_NUM" --repo "$REPO" 2>/dev/null \
| head -300 || true
} > pr-context.txt
echo "PR context saved ($(wc -l < pr-context.txt) lines)"
fi
- name: Run video review
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
TARGET_NUM: ${{ steps.pr.outputs.number }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
REPO: ${{ github.repository }}
run: |
mkdir -p video-reviews
PR_CTX_FLAG=""
if [ -f pr-context.txt ]; then
PR_CTX_FLAG="--pr-context pr-context.txt"
fi
TARGET_URL_FLAG=""
if [ -n "$TARGET_NUM" ]; then
if [ "$TARGET_TYPE" = "issue" ]; then
TARGET_URL_FLAG="--target-url https://github.com/${REPO}/issues/${TARGET_NUM}"
else
TARGET_URL_FLAG="--target-url https://github.com/${REPO}/pull/${TARGET_NUM}"
fi
fi
for vid in qa-artifacts/qa-report-*/qa-session*.mp4; do
[ -f "$vid" ] || continue
DIR=$(dirname "$vid")
BEFORE_FLAG=""
if [ -f "$DIR/qa-before-session.mp4" ]; then
BEFORE_FLAG="--before-video $DIR/qa-before-session.mp4"
fi
# Extract pass label from multi-pass filenames (qa-session-1.mp4 → pass1)
PASS_LABEL_FLAG=""
case "$(basename "$vid")" in
qa-session-[0-9].mp4)
PASS_NUM=$(basename "$vid" | sed 's/qa-session-\([0-9]\).mp4/\1/')
PASS_LABEL_FLAG="--pass-label pass${PASS_NUM}"
;;
esac
echo "::group::Reviewing $vid"
pnpm exec tsx scripts/qa-video-review.ts \
--artifacts-dir qa-artifacts \
--output-dir video-reviews \
--video-file "$vid" \
--model gemini-3-flash-preview $PR_CTX_FLAG $BEFORE_FLAG $TARGET_URL_FLAG $PASS_LABEL_FLAG || true
echo "::endgroup::"
done
- name: Generate regression test from QA report
if: needs.resolve-matrix.outputs.mode == 'focused' && steps.pr.outputs.target_type == 'pr'
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ steps.pr.outputs.number }}"
PR_BRANCH="${{ github.head_ref || github.ref_name }}"
if [ -z "$PR_NUM" ]; then
echo "No PR number, skipping test generation"
exit 0
fi
# Find the first QA report
REPORT=$(find video-reviews -name '*-qa-video-report.md' -type f | head -1)
if [ ! -f "$REPORT" ]; then
echo "No QA report found, skipping test generation"
exit 0
fi
# Ensure we have the PR diff
DIFF_FILE="${{ runner.temp }}/pr-diff.txt"
if [ ! -f "$DIFF_FILE" ]; then
gh pr diff "$PR_NUM" --repo "${{ github.repository }}" > "$DIFF_FILE" 2>/dev/null || true
fi
# Generate the test
TEST_NAME="qa-pr${PR_NUM}"
TEST_PATH="browser_tests/tests/${TEST_NAME}.spec.ts"
echo "::group::Generating regression test from QA report"
pnpm exec tsx scripts/qa-generate-test.ts \
--qa-report "$REPORT" \
--pr-diff "$DIFF_FILE" \
--output "$TEST_PATH" || {
echo "Test generation failed (non-fatal)"
exit 0
}
echo "::endgroup::"
# Push to {branch}-add-qa-test
TEST_BRANCH="${PR_BRANCH}-add-qa-test"
git checkout -b "$TEST_BRANCH" HEAD 2>/dev/null || git checkout "$TEST_BRANCH" 2>/dev/null || true
git add "$TEST_PATH"
git commit -m "test: add QA regression test for PR #${PR_NUM}" || {
echo "Nothing to commit"
exit 0
}
git push origin "$TEST_BRANCH" --force-with-lease || echo "Push failed (non-fatal)"
echo "Pushed regression test to branch: $TEST_BRANCH"
- name: Deploy to Cloudflare Pages
id: deploy-videos
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
RAW_BRANCH: ${{ github.head_ref || github.ref_name }}
BEFORE_SHA: ${{ needs.resolve-matrix.outputs.before_sha }}
AFTER_SHA: ${{ needs.resolve-matrix.outputs.after_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
TARGET_NUM: ${{ steps.pr.outputs.number }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: bash scripts/qa-deploy-pages.sh
- name: Post unified QA comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VIDEO_BASE: ${{ steps.deploy-videos.outputs.url }}
QA_MODE: ${{ needs.resolve-matrix.outputs.mode }}
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
BEFORE_SHA: ${{ needs.resolve-matrix.outputs.before_sha }}
AFTER_SHA: ${{ needs.resolve-matrix.outputs.after_sha }}
REPO: ${{ github.repository }}
run: |
RUN="https://github.com/${REPO}/actions/runs/${{ github.run_id }}"
COMMENT_MARKER="<!-- QA_REPORT_COMMENT -->"
MODE_BADGE="🔍 Focused"
if [ "$QA_MODE" = "full" ]; then MODE_BADGE="🔬 Full (3-OS)"; fi
if [ "$TARGET_TYPE" = "issue" ]; then MODE_BADGE="🐛 Issue Reproduce"; fi
# Build commit links
COMMIT_LINE=""
REPO_URL="https://github.com/${REPO}"
if [ -n "$BEFORE_SHA" ] || [ -n "$AFTER_SHA" ]; then
PARTS=""
[ -n "$BEFORE_SHA" ] && PARTS="main [\`${BEFORE_SHA:0:7}\`](${REPO_URL}/commit/${BEFORE_SHA})"
[ -n "$AFTER_SHA" ] && PARTS="${PARTS:+${PARTS} · }PR [\`${AFTER_SHA:0:7}\`](${REPO_URL}/commit/${AFTER_SHA})"
COMMIT_LINE="**Commits**: ${PARTS}"
fi
# Build video section with GIF thumbnails linking to full videos
VIDEO_SECTION=""
for os in Linux macOS Windows; do
GIF_URL="${VIDEO_BASE}/qa-${os}-thumb.gif"
VID_URL="${VIDEO_BASE}/qa-${os}.mp4"
if curl -sf --head "$VID_URL" >/dev/null 2>&1; then
if curl -sf --head "$GIF_URL" >/dev/null 2>&1; then
VIDEO_SECTION="${VIDEO_SECTION}[![${os} QA](${GIF_URL})](${VID_URL})"$'\n'
else
VIDEO_SECTION="${VIDEO_SECTION}[${os} video](${VID_URL})"$'\n'
fi
fi
done
# Build video review section from per-platform reports
VIDEO_REVIEW=""
for f in video-reviews/*-qa-video-report.md; do
[ -f "$f" ] || continue
[ -n "$VIDEO_REVIEW" ] && VIDEO_REVIEW="${VIDEO_REVIEW}
---
"
VIDEO_REVIEW="${VIDEO_REVIEW}$(cat "$f")"
done
VIDEO_REVIEW_SECTION=""
if [ -n "$VIDEO_REVIEW" ]; then
VIDEO_REVIEW_SECTION=$(cat <<REVIEWEOF
<details>
<summary>Video Review</summary>
${VIDEO_REVIEW}
</details>
REVIEWEOF
)
fi
BODY=$(cat <<EOF
${COMMENT_MARKER}
## QA ${MODE_BADGE}
[![QA Badge](${VIDEO_BASE}/badge.svg)](${VIDEO_BASE}/)
${VIDEO_SECTION}
**Run**: [${RUN}](${RUN}) · [Download artifacts](${RUN}#artifacts) · [All videos](${VIDEO_BASE})
${COMMIT_LINE:+${COMMIT_LINE}
}${VIDEO_REVIEW_SECTION}
EOF
)
PR_NUM="${{ steps.pr.outputs.number }}"
if [ -z "$PR_NUM" ]; then
echo "No PR found, skipping comment"
exit 0
fi
EXISTING=$(gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \
--jq ".[] | select(.body | contains(\"${COMMENT_MARKER}\")) | .id" | head -1)
if [ -n "$EXISTING" ]; then
gh api --method PATCH "repos/${{ github.repository }}/issues/comments/${EXISTING}" \
--field body="$BODY"
elif [ "$TARGET_TYPE" = "issue" ]; then
gh issue comment "$PR_NUM" \
--repo ${{ github.repository }} --body "$BODY"
else
gh pr comment "$PR_NUM" \
--repo ${{ github.repository }} --body "$BODY"
fi
- name: Cleanup old video review comments
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ steps.pr.outputs.number }}"
if [ -z "$PR_NUM" ]; then exit 0; fi
OLD_MARKER="<!-- QA_VIDEO_REVIEW_COMMENT -->"
gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \
--jq ".[] | select(.body | contains(\"${OLD_MARKER}\")) | .id" | \
while read -r comment_id; do
echo "Deleting old video review comment: $comment_id"
gh api --method DELETE "repos/${{ github.repository }}/issues/comments/${comment_id}" || true
done
- name: Remove QA label
if: >-
github.event.label.name == 'qa-changes' ||
github.event.label.name == 'qa-full'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LABEL_NAME: ${{ github.event.label.name }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
REPO: ${{ github.repository }}
run: |
[ -n "$PR_NUMBER" ] && gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL_NAME"

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"

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

@@ -85,6 +85,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -76,6 +76,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -201,6 +203,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.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
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -76,6 +76,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.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
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -144,6 +144,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -52,6 +52,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -30,6 +30,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

5
.gitignore vendored
View File

@@ -66,7 +66,6 @@ dist.zip
/temp/
/tmp/
.superpowers/
# Generated JSON Schemas
/schemas/
@@ -100,4 +99,6 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.playwright-cli/
.playwright/

View File

@@ -9,6 +9,7 @@
"packages/registry-types/src/comfyRegistryTypes.ts",
"public/materialdesignicons.min.css",
"src/types/generatedManagerTypes.ts",
"**/__fixtures__/**/*.json"
"**/__fixtures__/**/*.json",
"scripts/qa-report-template.html"
]
}

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

@@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codebase Caverns — ComfyUI Architecture Adventure</title>
<meta
name="description"
content="A prestige-driven architecture adventure game. Discover problems, learn patterns, make decisions, and watch the consequences unfold."
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,61 +0,0 @@
{
"name": "@comfyorg/architecture-adventure",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && tsx scripts/inline-build.ts",
"preview": "vite preview"
},
"devDependencies": {
"tsx": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
},
"nx": {
"tags": [
"scope:docs",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/architecture-adventure",
"command": "vite"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"command": "tsc --noEmit && vite build --config apps/architecture-adventure/vite.config.ts && tsx apps/architecture-adventure/scripts/inline-build.ts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/architecture-adventure",
"command": "vite preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/architecture-adventure",
"command": "tsc --noEmit"
}
}
}
}
}

View File

@@ -1,39 +0,0 @@
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const distDir = join(import.meta.dirname, '..', 'dist')
const htmlPath = join(distDir, 'index.html')
let html = readFileSync(htmlPath, 'utf-8')
const assetsDir = join(distDir, 'assets')
if (existsSync(assetsDir)) {
const assets = readdirSync(assetsDir)
// Inline CSS files
for (const file of assets) {
if (file.endsWith('.css')) {
const css = readFileSync(join(assetsDir, file), 'utf-8')
html = html.replace(
new RegExp(`<link[^>]*href="[./]*assets/${file}"[^>]*>`),
`<style>${css}</style>`
)
}
}
// Inline JS files
for (const file of assets) {
if (file.endsWith('.js')) {
const js = readFileSync(join(assetsDir, file), 'utf-8')
html = html.replace(
new RegExp(`<script[^>]*src="[./]*assets/${file}"[^>]*></script>`),
`<script type="module">${js}</script>`
)
}
}
}
writeFileSync(htmlPath, html)
const sizeKB = (Buffer.byteLength(html) / 1024).toFixed(1)
console.warn(`Single-file build complete: ${htmlPath} (${sizeKB} KB)`)

View File

@@ -1,476 +0,0 @@
import type { ChallengeDefinition } from '@/types'
const GH = 'https://github.com/Comfy-Org/ComfyUI_frontend/blob/main'
export const challenges: Record<string, ChallengeDefinition> = {
'circular-dependency': {
id: 'circular-dependency',
roomId: 'components',
title: 'The Circular Dependency',
tier: 1,
description:
'A tangled knot blocks the corridor ahead. Subgraph extends LGraph, ' +
'but LGraph creates and manages Subgraph instances. The circular import ' +
'forces order-dependent barrel exports and makes testing impossible in isolation. ' +
'How do you untangle it?',
recommended: 'A',
tagsGranted: ['composition'],
docLink: {
label: 'Entity Problems: Circular Dependencies',
url: `${GH}/docs/architecture/entity-problems.md`
},
choices: [
{
key: 'A',
label: 'Composition over inheritance',
hint: 'A subgraph IS a graph \u2014 just a node with a SubgraphStructure component. ECS eliminates class inheritance entirely.',
icon: 'components-a',
rating: 'good',
feedback:
'The circular dependency dissolves. Under graph unification, a subgraph is just a node carrying a SubgraphStructure component in a flat World. No inheritance, no special cases.',
tagsGranted: ['composition'],
insightReward: 1
},
{
key: 'B',
label: 'Barrel file reordering',
hint: 'Rearrange exports so the cycle resolves at module load time.',
icon: 'components-b',
rating: 'bad',
feedback:
'The imports stop crashing... for now. But the underlying coupling remains, and any new file touching both classes risks reviving the cycle.',
tagsGranted: [],
insightReward: 0
},
{
key: 'C',
label: 'Factory injection',
hint: 'Pass a graph factory function to break the static import cycle.',
icon: 'components-c',
rating: 'ok',
feedback:
"The factory breaks the import cycle cleanly. It's a pragmatic fix, though the classes remain tightly coupled at runtime.",
tagsGranted: [],
insightReward: 0
}
]
},
'scattered-mutations': {
id: 'scattered-mutations',
roomId: 'stores',
title: 'The Scattered Mutations',
tier: 1,
description:
'Deep in the vaults, you find a fragile counter: graph._version++. ' +
'It appears in 19 locations across 7 files \u2014 LGraph.ts (5 sites), ' +
'LGraphNode.ts (8 sites), LGraphCanvas.ts (2 sites), BaseWidget.ts, SubgraphInput.ts, ' +
'SubgraphInputNode.ts, SubgraphOutput.ts. ' +
'Change tracking depends on this scattered increment. One missed site means silent data loss.',
recommended: 'A',
tagsGranted: ['centralized-mutations'],
docLink: {
label: 'Migration Plan: Phase 0a',
url: `${GH}/docs/architecture/ecs-migration-plan.md`
},
choices: [
{
key: 'A',
label: 'Centralize into graph.incrementVersion()',
hint: 'Route all 19 sites through a single method. Phase 0a of the migration plan.',
icon: 'stores-a',
rating: 'good',
feedback:
'All 19 scattered increments now flow through one method. Change tracking becomes auditable, and the VersionSystem has a single hook point.',
tagsGranted: ['centralized-mutations'],
insightReward: 1
},
{
key: 'B',
label: 'Add a JavaScript Proxy',
hint: 'Intercept all writes to _version automatically.',
icon: 'stores-b',
rating: 'ok',
feedback:
'The Proxy catches mutations, but adds runtime overhead and makes debugging opaque. The scattered sites remain in the code.',
tagsGranted: [],
insightReward: 0
},
{
key: 'C',
label: 'Leave it as-is',
hint: "It works. Don't touch it.",
icon: 'stores-c',
rating: 'bad',
feedback:
'The team breathes a sigh of relief... until the next silent data loss bug from a missed increment site.',
tagsGranted: [],
insightReward: 0
}
]
},
'migration-question': {
id: 'migration-question',
roomId: 'services',
title: 'The Migration Question',
tier: 1,
description:
'A fork in the corridor. The legacy litegraph engine works \u2014 thousands of users ' +
'depend on it daily. But the architecture docs describe a better future: ECS with ' +
'branded types, pure systems, and a World registry. ' +
'How do you get from here to there without breaking production?',
recommended: 'A',
tagsGranted: ['incremental-migration'],
docLink: {
label: 'ECS Migration Plan',
url: `${GH}/docs/architecture/ecs-migration-plan.md`
},
choices: [
{
key: 'A',
label: '5-phase incremental plan',
hint: 'Foundation \u2192 Types \u2192 Bridge \u2192 Systems \u2192 Legacy Removal. Each phase is independently shippable.',
icon: 'services-a',
rating: 'good',
feedback:
'The team maps out five phases, each independently testable and shippable. Old and new coexist during transition. Production never breaks.',
tagsGranted: ['incremental-migration'],
insightReward: 1
},
{
key: 'B',
label: 'Big bang rewrite',
hint: 'Freeze features, rewrite everything in parallel, swap when ready.',
icon: 'services-b',
rating: 'bad',
feedback:
'Feature freeze begins. Weeks pass. The rewrite grows scope. Morale plummets. The old codebase drifts further from the new one.',
tagsGranted: [],
insightReward: 0
},
{
key: 'C',
label: 'Strangler fig pattern',
hint: 'Build new ECS beside old code, migrate consumers one by one.',
icon: 'services-c',
rating: 'ok',
feedback:
'A solid pattern. The new system grows organically around the old, though without a phased plan the migration lacks clear milestones.',
tagsGranted: [],
insightReward: 0
}
]
},
'god-object-dilemma': {
id: 'god-object-dilemma',
roomId: 'litegraph',
title: 'The God Object Dilemma',
tier: 2,
description:
'LGraphCanvas looms before you: ~9,100 lines of rendering, ' +
'input handling, selection, context menus, undo/redo, and more. LGraphNode ' +
'adds ~4,300 lines with ~539 method/property definitions mixing rendering, ' +
'serialization, connectivity, execution, layout, and state management. ' +
"These god objects are the root of most architectural pain. What's your approach?",
recommended: 'B',
tagsGranted: ['responsibility-extraction'],
docLink: {
label: 'Entity Problems: God Objects',
url: `${GH}/docs/architecture/entity-problems.md`
},
choices: [
{
key: 'A',
label: 'Rewrite from scratch',
hint: 'Tear it all down and rebuild with clean architecture from day one.',
icon: 'litegraph-a',
rating: 'bad',
feedback:
'The rewrite begins heroically... and stalls at month three. The team burns out reimplementing edge cases the god objects handled implicitly.',
tagsGranted: [],
insightReward: 0
},
{
key: 'B',
label: 'Extract incrementally',
hint: 'Peel responsibilities into focused modules one at a time. Position first, then connectivity, then rendering.',
icon: 'litegraph-b',
rating: 'good',
feedback:
"Position extraction lands first (it's already in LayoutStore). Then connectivity. Each extraction is a small, testable PR. The god objects shrink steadily.",
tagsGranted: ['responsibility-extraction'],
insightReward: 1
},
{
key: 'C',
label: 'Add a facade layer',
hint: 'Wrap the god objects with a clean API without changing internals.',
icon: 'litegraph-c',
rating: 'ok',
feedback:
'The facade provides a nicer API, but the complexity still lives behind it. New features still require diving into the god objects.',
tagsGranted: [],
insightReward: 0
}
]
},
'id-crossroads': {
id: 'id-crossroads',
roomId: 'ecs',
title: 'The ID Crossroads',
tier: 2,
description:
'The blueprints show a problem: NodeId is typed as number | string. ' +
'Nothing prevents passing a LinkId where a NodeId is expected. ' +
'Widgets are identified by name + parent node (fragile lookup). ' +
'Slots are identified by array index (breaks when reordered). ' +
'The six entity kinds \u2014 Node, Link, Widget, Slot, Reroute, Group \u2014 all ' +
'share the same untyped ID space. How do you bring type safety to this ID chaos?',
recommended: 'A',
tagsGranted: ['branded-types'],
docLink: {
label: 'ECS Target Architecture: Entity IDs',
url: `${GH}/docs/architecture/ecs-target-architecture.md`
},
choices: [
{
key: 'A',
label: 'Branded types with cast helpers',
hint: "type NodeEntityId = number & { __brand: 'NodeEntityId' } \u2014 compile-time safety, zero runtime cost.",
icon: 'ecs-a',
rating: 'good',
feedback:
'The compiler now catches cross-kind ID bugs. Cast helpers at system boundaries (asNodeEntityId()) keep the ergonomics clean. Phase 1a complete.',
tagsGranted: ['branded-types'],
insightReward: 1
},
{
key: 'B',
label: 'String prefixes at runtime',
hint: '"node:42", "link:7" \u2014 parse and validate at every usage site.',
icon: 'ecs-b',
rating: 'ok',
feedback:
'Runtime checks catch some bugs, but parsing overhead spreads everywhere. And someone will forget the prefix check in a hot path.',
tagsGranted: [],
insightReward: 0
},
{
key: 'C',
label: 'Keep plain numbers',
hint: 'Just be careful. Document which IDs are which.',
icon: 'ecs-c',
rating: 'bad',
feedback:
'The next developer passes a LinkId to a node lookup. The silent failure takes two days to debug in production.',
tagsGranted: [],
insightReward: 0
}
]
},
'widget-promotion': {
id: 'widget-promotion',
roomId: 'subgraph',
title: 'The Widget Promotion Decision',
tier: 2,
description:
'A user right-clicks a widget inside a subgraph and selects "Promote to parent." ' +
'Today this requires three layers: PromotionStore, PromotedWidgetViewManager, ' +
'and PromotedWidgetView \u2014 a parallel state system that duplicates what ' +
'the type-to-widget mapping already does for normal inputs. ' +
'Two candidates for the ECS future. The team must decide before Phase 3 solidifies.',
recommended: 'A',
tagsGranted: ['typed-contracts'],
docLink: {
label: 'Subgraph Boundaries: Widget Promotion',
url: `${GH}/docs/architecture/subgraph-boundaries-and-promotion.md`
},
choices: [
{
key: 'A',
label: 'Connections-only: promotion = adding a typed input',
hint: 'Promote a widget by adding an interface input. The type\u2192widget mapping creates the widget automatically. No new concepts.',
icon: 'subgraph-a',
rating: 'good',
feedback:
'PromotionStore, ViewManager, and PromotedWidgetView are eliminated entirely. Promotion becomes an operation on the subgraph\u2019s function signature. The existing slot, link, and widget infrastructure handles everything.',
tagsGranted: ['typed-contracts'],
insightReward: 1
},
{
key: 'B',
label: 'Simplified component promotion',
hint: 'A WidgetPromotion component on widget entities. Removes ViewManager but preserves promotion as a distinct concept.',
icon: 'subgraph-b',
rating: 'ok',
feedback:
'The ViewManager and proxy reconciliation are gone, but promotion remains a separate concept from connection. Shared subgraph instances face an open question: which source widget is authoritative?',
tagsGranted: [],
insightReward: 0
},
{
key: 'C',
label: 'Keep the current three-layer system',
hint: 'PromotionStore + ViewManager + PromotedWidgetView. It works today.',
icon: 'subgraph-c',
rating: 'bad',
feedback:
'The parallel state system persists. Every promoted widget is a shadow copy reconciled by a virtual DOM-like diffing layer. The ECS migration must work around it indefinitely.',
tagsGranted: [],
insightReward: 0
}
]
},
'render-time-mutation': {
id: 'render-time-mutation',
roomId: 'renderer',
title: 'The Render-Time Mutation',
tier: 2,
description:
'Alarms sound. The render pipeline has a critical flaw: drawNode() calls ' +
'_setConcreteSlots() and arrange() during the render pass. ' +
'The render phase mutates state, making draw order affect layout. ' +
"Node A's position depends on whether Node B was drawn first. " +
'How do you fix the pipeline?',
recommended: 'A',
tagsGranted: ['phase-separation'],
docLink: {
label: 'Entity Problems: Render-Time Mutations',
url: `${GH}/docs/architecture/entity-problems.md`
},
choices: [
{
key: 'A',
label: 'Separate update and render phases',
hint: 'Compute all layout in an update pass, then render as a pure read-only pass. Matches the ECS system pipeline.',
icon: 'renderer-a',
rating: 'good',
feedback:
'The pipeline becomes: Input \u2192 Update (layout, connectivity) \u2192 Render (read-only). Draw order no longer matters. Bugs vanish.',
tagsGranted: ['phase-separation'],
insightReward: 1
},
{
key: 'B',
label: 'Dirty flags and deferred render',
hint: 'Mark mutated nodes dirty, skip them, re-render next frame.',
icon: 'renderer-b',
rating: 'ok',
feedback:
"Dirty flags reduce the worst symptoms, but the render pass still has permission to mutate. It's a band-aid on an architectural wound.",
tagsGranted: [],
insightReward: 0
}
]
},
'collaboration-protocol': {
id: 'collaboration-protocol',
roomId: 'composables',
title: 'The Collaboration Protocol',
tier: 3,
description:
'A request arrives: multiple users want to edit the same workflow simultaneously. ' +
'The layoutStore already extracts position data from litegraph entities. ' +
'But how do you synchronize positions across users without conflicts?',
recommended: 'A',
tagsGranted: ['crdt-sync'],
docLink: {
label: 'Proto-ECS Stores: LayoutStore',
url: `${GH}/docs/architecture/proto-ecs-stores.md`
},
choices: [
{
key: 'A',
label: 'Y.js CRDTs',
hint: 'Conflict-free replicated data types. Merge without coordination. Already proven at scale.',
icon: 'composables-a',
rating: 'good',
feedback:
'Y.js CRDT maps back the layout store. Concurrent edits merge automatically. ADR 0003 is realized. The collaboration future is here.',
tagsGranted: ['crdt-sync'],
insightReward: 1
},
{
key: 'B',
label: 'Polling-based sync',
hint: 'Fetch full state every few seconds, merge manually, hope for the best.',
icon: 'composables-b',
rating: 'bad',
feedback:
'Polling creates a flickering, laggy experience. Two users move the same node and one edit is silently lost. Support tickets pile up.',
tagsGranted: [],
insightReward: 0
},
{
key: 'C',
label: 'Skip collaboration for now',
hint: 'Single-user editing only. Focus on other priorities.',
icon: 'composables-c',
rating: 'ok',
feedback:
'A pragmatic choice. The team focuses elsewhere. But the cloud product team is not happy about the delay.',
tagsGranted: [],
insightReward: 0
}
]
},
'mutation-gateway': {
id: 'mutation-gateway',
roomId: 'sidepanel',
title: 'The Mutation Gateway',
tier: 3,
description:
"A heated debate blocks the forge entrance. One faction argues the World's imperative " +
'API (world.setComponent()) conflicts with the command pattern requirement ' +
'from ADR 0003. Another faction says commands and the World serve different layers. ' +
'How should external callers mutate the World?',
recommended: 'A',
tagsGranted: ['command-layer'],
docLink: {
label: 'World API and Command Layer',
url: `${GH}/docs/architecture/ecs-world-command-api.md`
},
choices: [
{
key: 'A',
label: 'Commands as intent; systems as handlers; World as store',
hint: 'Caller \u2192 Command \u2192 System \u2192 World \u2192 Y.js. Commands are serializable. The World\u2019s imperative API is internal, called only by systems inside transactions.',
icon: 'sidepanel-a',
rating: 'good',
feedback:
'The layering clicks. Commands are serializable intent. Systems are command handlers. The World is the store \u2014 its imperative API is internal, just like Redux\u2019s state mutations inside reducers. ADR 0003 and ADR 0008 are complementary layers.',
tagsGranted: ['command-layer'],
insightReward: 1
},
{
key: 'B',
label: 'Make World.setComponent() itself serializable',
hint: 'Log every World mutation as a serializable operation. The World IS the command system.',
icon: 'sidepanel-b',
rating: 'ok',
feedback:
'This conflates the store with the command layer. Every internal implementation detail becomes part of the public API. Batch operations like Paste become dozens of logged mutations instead of one intent.',
tagsGranted: [],
insightReward: 0
},
{
key: 'C',
label: 'Skip commands \u2014 let callers mutate directly',
hint: 'External code calls world.setComponent() directly. Simpler. No ceremony.',
icon: 'sidepanel-c',
rating: 'bad',
feedback:
'Without a command layer, there is no undo/redo log, no replay, no CRDT sync, and no way to audit what changed. Every caller becomes responsible for transaction management.',
tagsGranted: [],
insightReward: 0
}
]
}
}

View File

@@ -1,13 +0,0 @@
import { rooms } from './rooms'
interface GraphEdge {
from: string
to: string
}
export const edges: GraphEdge[] = Object.values(rooms).flatMap((room) =>
room.connections.map((conn) => ({
from: room.id,
to: conn.targetRoomId
}))
)

View File

@@ -1,194 +0,0 @@
import type {
ChallengeRating,
NarrativeBridge,
NarrativeSection,
NarrativeSentence
} from '@/types'
const sentences: NarrativeSentence[] = [
{
challengeId: 'circular-dependency',
good: 'The circular dependency between Subgraph and LGraph dissolved completely. Composition replaced inheritance, and the flat World made special cases unnecessary.',
ok: 'A factory injection broke the import cycle, but the classes remain coupled at runtime. The next refactor will revisit this tension.',
bad: 'The circular dependency was papered over with barrel file reordering. It lurks beneath the surface, waiting for the next import to revive the cycle.'
},
{
challengeId: 'scattered-mutations',
good: 'All 19 scattered version increments were centralized into a single auditable method. Change tracking became reliable overnight.',
ok: 'A JavaScript Proxy intercepts version mutations, but the scattered increment sites remain in the code. Debugging has become more opaque.',
bad: 'The 19 scattered graph._version++ sites were left untouched. Silent data loss continues to haunt the team with every missed increment.'
},
{
challengeId: 'migration-question',
good: 'A 5-phase incremental migration plan was adopted. Each phase ships independently, and production never breaks during the transition.',
ok: 'The strangler fig pattern lets new ECS code grow beside the old, but without clear milestones the migration drifts without a timeline.',
bad: 'A big-bang rewrite was attempted. Feature freeze dragged on for months, morale collapsed, and the old codebase drifted beyond reconciliation.'
},
{
challengeId: 'god-object-dilemma',
good: 'The god objects are being dismantled incrementally. Position extraction shipped first, then connectivity. Each PR is small and testable.',
ok: 'A facade wraps the god objects with a cleaner API, but the 9,100-line monolith still lurks behind it. New features still require diving in.',
bad: 'The heroic rewrite stalled at month three. The team burned out reimplementing edge cases that the god objects handled implicitly.'
},
{
challengeId: 'id-crossroads',
good: 'Branded entity IDs now catch cross-kind bugs at compile time. Cast helpers at system boundaries keep ergonomics clean.',
ok: 'Runtime string prefixes catch some ID mix-ups, but parsing overhead spreads everywhere and hot-path checks are occasionally forgotten.',
bad: 'Plain untyped numbers remain the norm. A LinkId passed to a node lookup caused a silent failure that took two days to debug.'
},
{
challengeId: 'widget-promotion',
good: 'Widget promotion was unified with the connection system. Adding a typed interface input is all it takes \u2014 no parallel state, no shadow copies.',
ok: 'A simplified WidgetPromotion component replaced the ViewManager, but promotion remains a concept separate from connections.',
bad: 'The three-layer promotion system persists. Every promoted widget is a shadow copy reconciled by a diffing layer the ECS must work around.'
},
{
challengeId: 'render-time-mutation',
good: 'Update and render phases are now fully separated. The render pass is read-only, and draw order no longer affects layout.',
ok: 'Dirty flags reduced the worst render-time mutation symptoms, but the render pass still has permission to mutate state.',
bad: 'Render-time mutations continue unchecked. Node positions depend on draw order, and every new node type risks layout-dependent bugs.'
},
{
challengeId: 'collaboration-protocol',
good: 'Y.js CRDTs back the layout store. Concurrent edits merge automatically, and real-time collaboration is now a reality.',
ok: 'Collaboration was deferred to focus on other priorities. The cloud product team awaits, but the architecture is ready when the time comes.',
bad: 'Polling-based sync was implemented. Users experience flickering, lag, and silently lost edits. Support tickets pile up.'
},
{
challengeId: 'mutation-gateway',
good: 'The command layer is in place: serializable intent flows through systems into the World. Undo/redo, replay, and CRDT sync all work.',
ok: 'World mutations are logged as serializable operations, but the store and command layer are conflated. Batch operations produce excessive noise.',
bad: 'Without a command layer, callers mutate the World directly. There is no undo/redo, no replay, and no audit trail.'
}
]
const sections: NarrativeSection[] = [
{
id: 'legacy',
title: 'The Legacy',
challengeIds: [
'circular-dependency',
'god-object-dilemma',
'scattered-mutations'
],
introByTone: {
optimistic:
'The legacy codebase has been thoroughly understood and its worst patterns addressed.',
mixed:
'Some legacy patterns were addressed, while others remain embedded in the architecture.',
pessimistic:
'The legacy codebase retains most of its original pain points, resisting transformation.'
}
},
{
id: 'architecture',
title: 'The Architecture',
challengeIds: ['id-crossroads', 'mutation-gateway', 'render-time-mutation'],
introByTone: {
optimistic:
'The new architecture stands on solid foundations \u2014 type-safe, layered, and deterministic.',
mixed:
'The architectural vision is partially realized. Some foundations are strong, others compromise.',
pessimistic:
'The architectural redesign never fully materialized. Old and new patterns clash at every boundary.'
}
},
{
id: 'future',
title: 'The Future',
challengeIds: [
'migration-question',
'collaboration-protocol',
'widget-promotion'
],
introByTone: {
optimistic:
'The path forward is clear. Migration proceeds in phases, collaboration is live, and the ECS world hums with clean data.',
mixed:
'The future is promising but uncertain. Some migration paths are clear while others remain open questions.',
pessimistic:
'The migration stalls. Technical debt compounds, and the team struggles to chart a path through the complexity.'
}
}
]
const bridges: NarrativeBridge[] = [
{
fromSectionId: 'legacy',
toSectionId: 'architecture',
byTone: {
optimistic:
'With the legacy pain points addressed, the team turned to building the new architecture with confidence.',
mixed:
'Despite unresolved legacy issues, the team pressed forward with architectural decisions.',
pessimistic:
'The unaddressed legacy problems cast a long shadow over every architectural decision that followed.'
}
},
{
fromSectionId: 'architecture',
toSectionId: 'future',
byTone: {
optimistic:
'The solid architectural foundations enabled ambitious plans for migration and collaboration.',
mixed:
'With a mixed architectural foundation, the team faced the future with cautious optimism.',
pessimistic:
'Weak architectural foundations made every forward-looking decision feel like building on sand.'
}
}
]
function getSentenceMap(): Map<string, NarrativeSentence> {
return new Map(sentences.map((s) => [s.challengeId, s]))
}
type Tone = 'optimistic' | 'mixed' | 'pessimistic'
function sectionTone(
results: Record<string, { rating: ChallengeRating }>,
challengeIds: string[]
): Tone {
const ratings = challengeIds.map((id) => results[id]?.rating).filter(Boolean)
if (ratings.length === 0) return 'mixed'
const goodCount = ratings.filter((r) => r === 'good').length
const badCount = ratings.filter((r) => r === 'bad').length
if (goodCount >= ratings.length * 0.6) return 'optimistic'
if (badCount >= ratings.length * 0.6) return 'pessimistic'
return 'mixed'
}
export function buildNarrativeSummary(
results: Record<string, { rating: ChallengeRating }>
): string {
const sentenceMap = getSentenceMap()
const parts: string[] = []
for (let i = 0; i < sections.length; i++) {
const section = sections[i]
const tone = sectionTone(results, section.challengeIds)
parts.push(section.introByTone[tone])
for (const challengeId of section.challengeIds) {
const sentence = sentenceMap.get(challengeId)
const result = results[challengeId]
if (sentence && result) {
parts.push(sentence[result.rating])
}
}
if (i < bridges.length) {
const bridge = bridges[i]
const nextSection = sections[i + 1]
const bridgeTone = nextSection
? sectionTone(results, nextSection.challengeIds)
: tone
parts.push(bridge.byTone[bridgeTone])
}
}
return parts.join(' ')
}

View File

@@ -1,430 +0,0 @@
import type { RoomDefinition } from '@/types'
const GH = 'https://github.com/Comfy-Org/ComfyUI_frontend/blob/main'
export const rooms: Record<string, RoomDefinition> = {
entry: {
id: 'entry',
title: 'The Entry Point',
layer: 'src/main.ts',
discoveryDescription:
`You stand at ${GH}/src/main.ts, the entry point of the ComfyUI frontend. ` +
'The air hums with the bootstrapping of a Vue 3 application. Pinia stores ' +
'initialize around you, the router unfurls paths into the distance, and ' +
'i18n translations whisper in dozens of languages. ' +
'Three corridors stretch ahead, each leading deeper into the architecture. ' +
'Somewhere in this codebase, god objects lurk, mutations scatter in the shadows, ' +
'and a grand migration awaits your decisions.',
solutionDescription: '',
prerequisites: [],
artifacts: [],
connections: [
{
targetRoomId: 'components',
label: 'Enter the Component Gallery',
hint: 'Presentation Layer'
},
{
targetRoomId: 'stores',
label: 'Descend into the Store Vaults',
hint: 'State Management'
},
{
targetRoomId: 'services',
label: 'Follow the wires to Services',
hint: 'Business Logic'
}
]
},
components: {
id: 'components',
title: 'The Component Gallery',
layer: 'Presentation',
discoveryDescription:
'Vast halls lined with Vue Single File Components. GraphView.vue dominates the center \u2014 ' +
'the main canvas workspace where nodes are wired together. But a tangled knot blocks ' +
'the corridor ahead: Subgraph extends LGraph, and LGraph creates Subgraph instances. ' +
'The circular import forces order-dependent barrel exports and makes testing impossible ' +
'in isolation.',
solutionDescription:
'The circular dependency dissolves when you realize a subgraph is just a node ' +
'carrying a SubgraphStructure component. Composition replaces inheritance, and the ' +
'flat World eliminates special cases entirely.',
prerequisites: [],
artifacts: [
{ name: 'GraphView.vue', type: 'Component', icon: 'graphview' }
],
connections: [
{
targetRoomId: 'litegraph',
label: 'Inspect the Canvas',
hint: 'Litegraph Engine'
},
{
targetRoomId: 'sidepanel',
label: 'Enter the Command Forge',
hint: 'Commands & Intent'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'circular-dependency'
},
stores: {
id: 'stores',
title: 'The Store Vaults',
layer: 'State',
discoveryDescription:
'Sixty Pinia stores line the walls like vault doors, each guarding a domain of reactive state. ' +
'Deep in the vaults, you find a fragile counter: graph._version++. It appears in 19 locations ' +
'across 7 files \u2014 LGraph.ts, LGraphNode.ts, LGraphCanvas.ts, BaseWidget.ts, SubgraphInput.ts, ' +
'SubgraphInputNode.ts, SubgraphOutput.ts. Change tracking depends on this scattered increment. ' +
'One missed site means silent data loss.',
solutionDescription:
'Centralizing all 19 increment sites into a single graph.incrementVersion() method makes ' +
'change tracking auditable. The VersionSystem gains a single hook point, and Phase 0a ' +
'of the migration plan is complete.',
prerequisites: [],
artifacts: [
{
name: 'widgetValueStore.ts',
type: 'Proto-ECS Store',
icon: 'widgetvaluestore'
},
{
name: 'layoutStore.ts',
type: 'Proto-ECS Store',
icon: 'layoutstore'
}
],
connections: [
{
targetRoomId: 'ecs',
label: 'Examine the ECS Blueprints',
hint: 'Entity-Component-System'
},
{
targetRoomId: 'renderer',
label: 'Visit the Renderer',
hint: 'Canvas & Layout'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'scattered-mutations'
},
services: {
id: 'services',
title: 'The Service Corridors',
layer: 'Services',
discoveryDescription:
'Clean corridors of orchestration logic. litegraphService.ts manages graph creation and ' +
'serialization. extensionService.ts loads third-party extensions. But a fork in the corridor ' +
'reveals the core tension: the legacy litegraph engine works \u2014 thousands of users depend on ' +
'it daily \u2014 yet the architecture docs describe a better future with ECS, branded types, and ' +
'a World registry. How do you get from here to there without breaking production?',
solutionDescription:
'A 5-phase incremental migration plan maps the path forward. Each phase is independently ' +
'testable and shippable. Old and new coexist during transition. Production never breaks.',
prerequisites: [],
artifacts: [
{
name: 'litegraphService.ts',
type: 'Service',
icon: 'litegraphservice'
},
{
name: 'Extension Migration Guide',
type: 'Design Pattern',
icon: 'extension-migration'
}
],
connections: [
{
targetRoomId: 'composables',
label: 'Follow the Composables',
hint: 'Reusable Logic Hooks'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'migration-question'
},
litegraph: {
id: 'litegraph',
title: 'The Litegraph Engine Room',
layer: 'Graph Engine',
discoveryDescription:
"The beating heart of ComfyUI's visual programming. Massive class files loom: " +
'LGraphCanvas.ts at ~9,100 lines handles all rendering and interaction, ' +
'LGraphNode.ts at ~4,300 lines is the god-object node entity, and ' +
'LGraph.ts at ~3,100 lines contains the graph itself. ' +
'These god objects are the root of most architectural pain \u2014 circular dependencies, ' +
'render-time side effects, and scattered mutation sites.',
solutionDescription:
'Incremental extraction peels responsibilities into focused modules one at a time. ' +
'Position extraction lands first (already in LayoutStore), then connectivity. ' +
'Each extraction is a small, testable PR. The god objects shrink steadily.',
prerequisites: ['composition'],
artifacts: [
{
name: 'LGraphCanvas.ts',
type: 'God Object',
icon: 'lgraphcanvas'
},
{ name: 'LGraphNode.ts', type: 'God Object', icon: 'lgraphnode' }
],
connections: [
{
targetRoomId: 'ecs',
label: 'Examine the ECS Blueprints',
hint: 'The planned future'
},
{
targetRoomId: 'components',
label: 'Return to Components',
hint: 'Presentation Layer'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'god-object-dilemma'
},
ecs: {
id: 'ecs',
title: "The ECS Architect's Chamber",
layer: 'ECS',
discoveryDescription:
'Blueprints cover every surface. The Entity-Component-System architecture is taking shape: ' +
'six entity kinds \u2014 Node, Link, Widget, Slot, Reroute, Group \u2014 each identified by ' +
'untyped IDs. NodeId is typed as number | string. Nothing prevents passing a LinkId where ' +
'a NodeId is expected. Widgets are identified by name + parent node (fragile lookup). ' +
'Slots are identified by array index (breaks when reordered). The six entity kinds all ' +
'share the same untyped ID space.',
solutionDescription:
'Branded types with cast helpers bring compile-time safety at zero runtime cost. ' +
'type NodeEntityId = number & { __brand: "NodeEntityId" }. Cast helpers at system ' +
'boundaries keep ergonomics clean. Phase 1a is complete.',
prerequisites: ['centralized-mutations'],
artifacts: [
{
name: 'World Registry',
type: 'ECS Core',
icon: 'world-registry'
},
{
name: 'Branded Entity IDs',
type: 'Type Safety',
icon: 'branded-ids'
}
],
connections: [
{
targetRoomId: 'subgraph',
label: 'Descend into the Subgraph Depths',
hint: 'Boundaries & Promotion'
},
{
targetRoomId: 'renderer',
label: 'Visit the Renderer',
hint: 'Canvas & Layout'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'id-crossroads'
},
sidepanel: {
id: 'sidepanel',
title: 'The Command Forge',
layer: 'Commands & Intent',
discoveryDescription:
'You enter a forge where raw user intent is shaped into structured commands. ' +
"A heated debate blocks the forge entrance. One faction argues the World's imperative " +
'API (world.setComponent()) conflicts with the command pattern requirement from ADR 0003. ' +
'Another faction says commands and the World serve different layers. ' +
'How should external callers mutate the World?',
solutionDescription:
'Commands are serializable intent. Systems are command handlers. The World is the store \u2014 ' +
"its imperative API is internal, just like Redux's state mutations inside reducers. " +
'ADR 0003 and ADR 0008 are complementary layers.',
prerequisites: ['branded-types'],
artifacts: [
{
name: 'CommandExecutor',
type: 'ECS Core',
icon: 'command-executor'
},
{
name: 'Command Interface',
type: 'Design Pattern',
icon: 'command-interface'
}
],
connections: [
{
targetRoomId: 'components',
label: 'Return to the Component Gallery',
hint: 'Presentation Layer'
},
{
targetRoomId: 'stores',
label: 'Descend into the Store Vaults',
hint: 'State Management'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'mutation-gateway'
},
subgraph: {
id: 'subgraph',
title: 'The Subgraph Depths',
layer: 'Graph Boundaries',
discoveryDescription:
'You descend into nested chambers, each a perfect replica of the one above \u2014 graphs ' +
'within graphs within graphs. The current code tells a painful story: Subgraph extends LGraph, ' +
'virtual nodes with magic IDs (SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20), and three ' +
'layers of indirection at every boundary crossing. Widget promotion requires PromotionStore, ' +
'PromotedWidgetViewManager, and PromotedWidgetView \u2014 a parallel state system duplicating ' +
'what the type-to-widget mapping already handles.',
solutionDescription:
"Under graph unification, promotion becomes an operation on the subgraph's function signature. " +
'Promote a widget by adding an interface input. The type-to-widget mapping creates the widget ' +
'automatically. PromotionStore, ViewManager, and PromotedWidgetView are eliminated entirely.',
prerequisites: ['branded-types', 'composition'],
artifacts: [
{
name: 'SubgraphStructure',
type: 'ECS Component',
icon: 'subgraph-structure'
},
{
name: 'Typed Interface Contracts',
type: 'Design Pattern',
icon: 'typed-contracts'
}
],
connections: [
{
targetRoomId: 'ecs',
label: 'Return to the ECS Chamber',
hint: 'Entity-Component-System'
},
{
targetRoomId: 'litegraph',
label: 'Visit the Litegraph Engine Room',
hint: 'Graph Engine'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'widget-promotion'
},
renderer: {
id: 'renderer',
title: 'The Renderer Overlook',
layer: 'Renderer',
discoveryDescription:
'From here you can see the entire canvas rendering pipeline. But alarms sound: ' +
'drawNode() calls _setConcreteSlots() and arrange() during the render pass. ' +
'The render phase mutates state, making draw order affect layout. ' +
"Node A's position depends on whether Node B was drawn first. " +
'This is a critical pipeline flaw.',
solutionDescription:
'Separating update and render phases fixes the pipeline: Input \u2192 Update (layout, connectivity) ' +
'\u2192 Render (read-only). Draw order no longer matters. The ECS system pipeline enforces ' +
'this separation structurally.',
prerequisites: ['responsibility-extraction'],
artifacts: [
{
name: 'QuadTree Spatial Index',
type: 'Data Structure',
icon: 'quadtree'
},
{
name: 'Y.js CRDT Layout',
type: 'Collaboration',
icon: 'yjs-crdt'
}
],
connections: [
{
targetRoomId: 'ecs',
label: 'Examine the ECS Blueprints',
hint: 'Entity-Component-System'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'render-time-mutation'
},
composables: {
id: 'composables',
title: 'The Composables Workshop',
layer: 'Composables',
discoveryDescription:
'Hooks hang from the walls, each a reusable piece of Vue composition logic. ' +
'useCoreCommands.ts is the largest at 42KB \u2014 an orchestrator binding keyboard ' +
'shortcuts to application commands. A request arrives: multiple users want to edit ' +
'the same workflow simultaneously. The layoutStore already extracts position data ' +
'from litegraph entities. But how do you synchronize positions across users without conflicts?',
solutionDescription:
'Y.js CRDTs back the layout store. Concurrent edits merge automatically without coordination. ' +
'ADR 0003 is realized. The collaboration future is here.',
prerequisites: ['incremental-migration'],
artifacts: [
{
name: 'useCoreCommands.ts',
type: 'Composable',
icon: 'usecorecommands'
}
],
connections: [
{
targetRoomId: 'stores',
label: 'Descend into the Store Vaults',
hint: 'State Management'
},
{
targetRoomId: 'entry',
label: 'Return to the Entry Point',
hint: 'src/main.ts'
}
],
challengeId: 'collaboration-protocol'
}
}

View File

@@ -1,15 +0,0 @@
import type { SaveState } from '@/types'
function isRoomDiscovered(roomId: string, save: SaveState): boolean {
return save.currentRun.path.includes(roomId)
}
function isChallengeResolved(challengeId: string, save: SaveState): boolean {
return challengeId in save.currentRun.resolvedChallenges
}
function countResolvedChallenges(save: SaveState): number {
return Object.keys(save.currentRun.resolvedChallenges).length
}
export { countResolvedChallenges, isChallengeResolved, isRoomDiscovered }

View File

@@ -1,107 +0,0 @@
import type {
ChallengeDefinition,
ChallengeResult,
GamePhase,
GameState,
SaveState
} from '@/types'
import { persistSave } from '@/state/gameState'
import { grantTags } from '@/state/tags'
type GameEventHandler = (state: GameState) => void
let currentState: GameState
let listeners: GameEventHandler[] = []
function initGameState(save: SaveState): void {
currentState = {
phase: 'exploring',
save
}
notify()
}
function subscribe(handler: GameEventHandler): () => void {
listeners.push(handler)
return () => {
listeners = listeners.filter((l) => l !== handler)
}
}
function notify(): void {
for (const listener of listeners) {
listener(currentState)
}
}
function transition(phase: GamePhase, saveUpdates?: Partial<SaveState>): void {
const newSave = saveUpdates
? { ...currentState.save, ...saveUpdates }
: currentState.save
currentState = { phase, save: newSave }
persistSave(currentState.save)
notify()
}
function enterRoom(roomId: string): void {
const run = currentState.save.currentRun
const newPath = run.path.includes(roomId) ? run.path : [...run.path, roomId]
transition('exploring', {
currentRun: {
...run,
currentRoom: roomId,
path: newPath
}
})
}
function resolveChallenge(
challenge: ChallengeDefinition,
choiceKey: string
): void {
const choice = challenge.choices.find((c) => c.key === choiceKey)
if (!choice) return
const result: ChallengeResult = {
choiceKey,
rating: choice.rating,
tier: challenge.tier
}
let save = {
...currentState.save,
currentRun: {
...currentState.save.currentRun,
resolvedChallenges: {
...currentState.save.currentRun.resolvedChallenges,
[challenge.id]: result
},
insightEarned:
currentState.save.currentRun.insightEarned + choice.insightReward
}
}
save = grantTags(save, challenge.tagsGranted)
save = grantTags(save, choice.tagsGranted)
transition('challenge-resolved', save)
}
function showEnding(): void {
transition('ending')
}
function resetForPrestige(newSave: SaveState): void {
transition('exploring', newSave)
}
export {
enterRoom,
initGameState,
resetForPrestige,
resolveChallenge,
showEnding,
subscribe
}

View File

@@ -1,26 +0,0 @@
import './style/theme.css'
import './style/layout.css'
import './style/hud.css'
import './style/room.css'
import './style/challenge.css'
import './style/sidebar.css'
import './style/map.css'
import './style/animations.css'
import { isV1Save, loadSave } from '@/state/gameState'
import { enterRoom, initGameState, subscribe } from '@/engine/stateMachine'
import { mountApp, render } from '@/ui/renderer'
function main(): void {
if (isV1Save()) {
console.warn('Codebase Caverns v1 save detected. Starting fresh for v2.')
}
const save = loadSave()
mountApp()
initGameState(save)
subscribe(render)
enterRoom(save.currentRun.currentRoom)
}
main()

View File

@@ -1,67 +0,0 @@
import type { CurrentRun, Layer, SaveState } from '@/types'
const STORAGE_KEY = 'codebase-caverns-v2'
const SAVE_VERSION = 1
function createFreshRun(layer: Layer): CurrentRun {
return {
layer,
path: [],
resolvedChallenges: {},
conceptTags: [],
insightEarned: 0,
currentRoom: 'entry'
}
}
function createDefaultSave(): SaveState {
return {
version: SAVE_VERSION,
currentRun: createFreshRun(1),
history: [],
persistent: {
totalInsight: 0,
currentLayer: 1,
achievements: []
}
}
}
function loadSave(): SaveState {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return createDefaultSave()
const parsed: unknown = JSON.parse(raw)
if (
typeof parsed === 'object' &&
parsed !== null &&
'version' in parsed &&
(parsed as SaveState).version === SAVE_VERSION
) {
return parsed as SaveState
}
return createDefaultSave()
} catch {
return createDefaultSave()
}
}
function persistSave(save: SaveState): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(save))
}
function clearSave(): void {
localStorage.removeItem(STORAGE_KEY)
}
function isV1Save(): boolean {
try {
const raw = localStorage.getItem('codebase-caverns')
return raw !== null
} catch {
return false
}
}
export { clearSave, createFreshRun, isV1Save, loadSave, persistSave }

View File

@@ -1,36 +0,0 @@
import type { Layer, RunRecord, SaveState } from '@/types'
import { createFreshRun } from '@/state/gameState'
function finalizeRun(save: SaveState, narrativeSummary: string): RunRecord {
return {
layer: save.currentRun.layer,
path: save.currentRun.path,
challenges: { ...save.currentRun.resolvedChallenges },
conceptTags: [...save.currentRun.conceptTags],
insightEarned: save.currentRun.insightEarned,
narrativeSummary
}
}
function canPrestige(save: SaveState): boolean {
return save.persistent.currentLayer < 3
}
function prestige(save: SaveState, narrativeSummary: string): SaveState {
const record = finalizeRun(save, narrativeSummary)
const nextLayer = Math.min(save.persistent.currentLayer + 1, 3) as Layer
return {
...save,
currentRun: createFreshRun(nextLayer),
history: [...save.history, record],
persistent: {
...save.persistent,
totalInsight:
save.persistent.totalInsight + save.currentRun.insightEarned,
currentLayer: nextLayer
}
}
}
export { canPrestige, prestige }

View File

@@ -1,22 +0,0 @@
import type { RoomDefinition, SaveState } from '@/types'
function canEnterRoom(room: RoomDefinition, save: SaveState): boolean {
return room.prerequisites.every((tag) =>
save.currentRun.conceptTags.includes(tag)
)
}
function grantTags(save: SaveState, tags: string[]): SaveState {
const newTags = tags.filter((t) => !save.currentRun.conceptTags.includes(t))
if (newTags.length === 0) return save
return {
...save,
currentRun: {
...save.currentRun,
conceptTags: [...save.currentRun.conceptTags, ...newTags]
}
}
}
export { canEnterRoom, grantTags }

View File

@@ -1,46 +0,0 @@
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes unlockPulse {
0% {
box-shadow: 0 0 0 0 rgb(88 166 255 / 0.4);
}
70% {
box-shadow: 0 0 0 8px rgb(88 166 255 / 0);
}
100% {
box-shadow: 0 0 0 0 rgb(88 166 255 / 0);
}
}
@keyframes nodeUnlock {
0% {
opacity: 0.3;
transform: scale(0.9);
}
60% {
transform: scale(1.05);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.map-node.newly-unlocked circle {
animation: unlockPulse 0.6s ease-out;
}
.map-node {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}

View File

@@ -1,208 +0,0 @@
#challenge-panel {
border: 2px solid var(--yellow);
border-radius: 10px;
overflow: hidden;
display: none;
animation: fadeSlideIn 0.3s ease;
}
#challenge-panel.active {
display: block;
}
#challenge-header {
background: rgb(210 153 34 / 0.1);
padding: 12px 16px;
border-bottom: 1px solid var(--yellow);
display: flex;
align-items: center;
gap: 8px;
}
#challenge-header .icon {
font-size: 16px;
}
#challenge-title {
font-size: 16px;
font-weight: 600;
color: var(--yellow);
}
#challenge-desc {
padding: 14px 18px;
font-size: 15px;
line-height: 1.8;
color: var(--muted);
}
#challenge-desc code {
background: var(--surface);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 3px;
color: var(--accent);
font-size: 14px;
}
#challenge-desc a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dotted var(--accent);
}
#challenge-desc a:hover {
border-bottom-style: solid;
}
#challenge-choices {
padding: 8px 16px 16px;
display: flex;
gap: 10px;
}
.challenge-choice-btn {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0;
color: var(--text);
font-family: inherit;
font-size: 15px;
cursor: pointer;
text-align: center;
transition: all 0.15s;
display: flex;
flex-direction: column;
overflow: hidden;
}
.challenge-choice-btn:hover {
border-color: var(--yellow);
background: rgb(210 153 34 / 0.08);
transform: translateY(-2px);
}
.challenge-choice-btn .choice-icon-wrap {
position: relative;
background: var(--bg);
padding: 12px;
}
.challenge-choice-btn .choice-key {
position: absolute;
top: 6px;
left: 6px;
background: var(--yellow);
color: var(--bg);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
line-height: 1.4;
}
.challenge-choice-btn .choice-icon {
width: 56px;
height: 56px;
border-radius: 8px;
border: 1px solid var(--border);
overflow: hidden;
margin: 0 auto;
}
.challenge-choice-btn .choice-icon img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.challenge-choice-btn .choice-text {
padding: 10px 12px 14px;
}
.challenge-choice-btn .choice-label {
display: block;
font-weight: 600;
font-size: 14px;
}
.challenge-choice-btn .choice-hint {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 4px;
line-height: 1.4;
}
#result-banner {
border-radius: 8px;
padding: 12px 16px;
display: none;
animation: fadeSlideIn 0.3s ease;
font-size: 15px;
line-height: 1.5;
}
#result-banner.active {
display: block;
}
#result-banner.good {
border: 1px solid var(--green);
background: rgb(63 185 80 / 0.08);
color: var(--green);
}
#result-banner.ok {
border: 1px solid var(--yellow);
background: rgb(210 153 34 / 0.08);
color: var(--yellow);
}
#result-banner.bad {
border: 1px solid var(--red);
background: rgb(248 81 73 / 0.08);
color: var(--red);
}
.stat-delta {
font-weight: 600;
font-size: 11px;
}
.stat-delta.positive {
color: var(--green);
}
.stat-delta.negative {
color: var(--red);
}
.result-recommended {
margin-top: 10px;
padding: 8px 12px;
background: rgb(88 166 255 / 0.06);
border: 1px solid var(--accent);
border-radius: 6px;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.result-recommended strong {
color: var(--accent);
}
.result-doc-link {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dotted var(--accent);
font-size: 11px;
}
.result-doc-link:hover {
border-bottom-style: solid;
}

View File

@@ -1,92 +0,0 @@
#hud,
.choice-key,
.sidebar-header,
#room-layer,
#challenge-header,
#toggle-map,
.choice-btn .choice-hint,
.challenge-choice-btn .choice-hint {
user-select: none;
}
#hud {
position: sticky;
top: 0;
z-index: 10;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 32px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
#hud h1 {
font-size: 16px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.5px;
}
#hud-right {
display: flex;
align-items: center;
gap: 16px;
}
#restart-btn {
background: none;
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 10px;
border-radius: 4px;
font-family: inherit;
font-size: 11px;
cursor: pointer;
}
#restart-btn:hover {
border-color: var(--red);
color: var(--red);
}
#toggle-map {
background: none;
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 10px;
border-radius: 4px;
font-family: inherit;
font-size: 11px;
cursor: pointer;
}
#toggle-map:hover {
border-color: var(--accent);
color: var(--accent);
}
@media (max-width: 768px) {
#hud {
padding: 6px 12px;
gap: 6px;
}
#hud h1 {
font-size: 11px;
}
#hud-right {
width: 100%;
flex-wrap: wrap;
gap: 6px;
}
#restart-btn,
#toggle-map {
padding: 3px 8px;
font-size: 10px;
}
}

View File

@@ -1,38 +0,0 @@
#main {
display: flex;
max-width: 1600px;
width: 100%;
margin: 0 auto;
padding: 32px;
gap: 32px;
}
#sidebar {
flex: 1;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 60px;
align-self: flex-start;
max-height: calc(100vh - 80px);
overflow-y: auto;
}
@media (max-width: 768px) {
#main {
flex-direction: column;
padding: 16px;
gap: 16px;
max-width: 100%;
}
#sidebar {
min-width: unset;
width: 100%;
align-self: stretch;
position: static;
max-height: none;
}
}

View File

@@ -1,103 +0,0 @@
#map-dialog {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px;
max-width: 700px;
width: 90%;
color: var(--text);
box-shadow: 0 20px 60px rgb(0 0 0 / 0.6);
opacity: 0;
transform: scale(0.95);
transition:
opacity 0.2s ease,
transform 0.2s ease,
overlay 0.2s ease allow-discrete,
display 0.2s ease allow-discrete;
}
#map-dialog[open] {
opacity: 1;
transform: scale(1);
}
@starting-style {
#map-dialog[open] {
opacity: 0;
transform: scale(0.95);
}
}
#map-dialog::backdrop {
background: rgb(0 0 0 / 0.5);
opacity: 0;
transition:
opacity 0.2s ease,
overlay 0.2s ease allow-discrete,
display 0.2s ease allow-discrete;
}
#map-dialog[open]::backdrop {
opacity: 1;
}
@starting-style {
#map-dialog[open]::backdrop {
opacity: 0;
}
}
#map-dialog h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
margin-bottom: 12px;
}
.map-node circle {
transition:
fill 0.3s ease,
stroke 0.3s ease;
}
.map-node.locked circle {
fill: var(--bg);
stroke: var(--border);
}
.map-node.visited circle {
fill: var(--surface);
stroke: var(--green);
}
.map-node.current circle {
fill: var(--accent-dim);
stroke: var(--accent);
}
.map-edge {
stroke: var(--border);
stroke-width: 1.5;
}
.map-label {
fill: var(--text);
font-size: 11px;
font-family: var(--font-mono);
}
.map-title {
fill: var(--muted);
font-size: 9px;
text-transform: uppercase;
font-family: var(--font-mono);
}
.map-badge {
font-size: 10px;
}
.map-lock {
font-size: 12px;
}

View File

@@ -1,118 +0,0 @@
#room-header {
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
#room-header h2 {
font-size: 26px;
color: var(--text);
margin-bottom: 4px;
}
#room-layer {
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.room-image {
aspect-ratio: 21 / 9;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.room-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.room-image.placeholder {
background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 50%, #161b22 100%);
border-style: dashed;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
text-align: center;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
font-style: italic;
}
#room-description {
font-size: 16px;
line-height: 1.8;
color: var(--muted);
}
#room-description code {
background: var(--surface);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 3px;
color: var(--accent);
font-size: 15px;
}
#room-description a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dotted var(--accent);
}
#room-description a:hover {
border-bottom-style: solid;
}
#room-choices {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.choice-btn {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
color: var(--text);
font-family: inherit;
font-size: 15px;
cursor: pointer;
text-align: left;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 10px;
}
.choice-btn:hover {
border-color: var(--accent);
background: var(--accent-dim);
}
.choice-btn .choice-key {
background: var(--border);
color: var(--text);
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
min-width: 24px;
text-align: center;
}
.choice-btn .choice-label {
flex: 1;
}
.choice-btn .choice-hint {
font-size: 13px;
color: var(--muted);
}

View File

@@ -1,41 +0,0 @@
.sidebar-header {
background: var(--surface);
padding: 8px 12px;
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
}
.log-entry {
padding: 4px 6px;
border-radius: 4px;
color: var(--muted);
line-height: 1.5;
}
.log-entry.discovery {
color: var(--green);
}
.log-entry.warning {
color: var(--yellow);
}
.log-entry.error {
color: var(--red);
}
.log-entry.ending {
color: var(--purple);
}
.empty-hint {
padding: 8px;
font-size: 12px;
color: var(--muted);
font-style: italic;
}

View File

@@ -1,77 +0,0 @@
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #9ea7b0;
--accent: #58a6ff;
--accent-dim: #1f6feb33;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #bc8cff;
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
body {
font-family: var(--font-mono);
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
img,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
background: var(--surface);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 4px;
font-size: 0.9em;
}
dialog {
margin: auto;
}

View File

@@ -1,146 +0,0 @@
// --- Enumerations ---
type Layer = 1 | 2 | 3
type ChallengeRating = 'good' | 'ok' | 'bad'
type GamePhase =
| 'exploring'
| 'challenge-available'
| 'challenge-resolved'
| 'ending'
| 'prestige'
// --- Room & Challenge Data ---
interface RoomConnection {
targetRoomId: string
label: string
hint: string
}
interface Artifact {
name: string
type: string
icon: string
}
interface RoomDefinition {
id: string
title: string
layer: string
discoveryDescription: string
solutionDescription: string
prerequisites: string[]
artifacts: Artifact[]
connections: RoomConnection[]
challengeId?: string
imageUrl?: string
}
interface ChallengeChoice {
key: string
label: string
hint: string
icon: string
rating: ChallengeRating
feedback: string
tagsGranted: string[]
insightReward: number
}
interface ChallengeDefinition {
id: string
roomId: string
title: string
tier: number
description: string
recommended: string
docLink?: { label: string; url: string }
tagsGranted: string[]
choices: ChallengeChoice[]
}
// --- Narrative ---
interface NarrativeSentence {
challengeId: string
good: string
ok: string
bad: string
}
interface NarrativeSection {
id: string
title: string
challengeIds: string[]
introByTone: { optimistic: string; mixed: string; pessimistic: string }
}
interface NarrativeBridge {
fromSectionId: string
toSectionId: string
byTone: { optimistic: string; mixed: string; pessimistic: string }
}
// --- Save State ---
interface ChallengeResult {
choiceKey: string
rating: ChallengeRating
tier: number
}
interface RunRecord {
layer: Layer
path: string[]
challenges: Record<string, ChallengeResult>
conceptTags: string[]
insightEarned: number
narrativeSummary: string
}
interface CurrentRun {
layer: Layer
path: string[]
resolvedChallenges: Record<string, ChallengeResult>
conceptTags: string[]
insightEarned: number
currentRoom: string
}
interface PersistentState {
totalInsight: number
currentLayer: Layer
achievements: string[]
}
interface SaveState {
version: number
currentRun: CurrentRun
history: RunRecord[]
persistent: PersistentState
}
// --- Engine State ---
interface GameState {
phase: GamePhase
save: SaveState
}
export type {
ChallengeDefinition,
ChallengeRating,
ChallengeResult,
CurrentRun,
GamePhase,
GameState,
Layer,
NarrativeBridge,
NarrativeSection,
NarrativeSentence,
RoomDefinition,
RunRecord,
SaveState
}

View File

@@ -1,117 +0,0 @@
import type { ChallengeDefinition, GameState } from '@/types'
import { challenges } from '@/data/challenges'
import { rooms } from '@/data/rooms'
import { isChallengeResolved } from '@/engine/navigation'
import { resolveChallenge } from '@/engine/stateMachine'
function renderChallenge(state: GameState): void {
const mount = document.getElementById('challenge-mount')
if (!mount) return
mount.innerHTML = ''
const roomId = state.save.currentRun.currentRoom
const room = rooms[roomId]
if (!room?.challengeId) return
const challenge = challenges[room.challengeId]
if (!challenge) return
if (isChallengeResolved(challenge.id, state.save)) {
mount.appendChild(renderResultBanner(challenge, state))
return
}
mount.appendChild(renderChallengePanel(challenge))
}
function renderChallengePanel(challenge: ChallengeDefinition): HTMLElement {
const panel = document.createElement('div')
panel.id = 'challenge-panel'
panel.className = 'active'
const header = document.createElement('div')
header.id = 'challenge-header'
header.innerHTML = `
<span class="icon">⚡</span>
<span id="challenge-title">${challenge.title}</span>
`
const desc = document.createElement('div')
desc.id = 'challenge-desc'
desc.textContent = challenge.description
const choicesEl = document.createElement('div')
choicesEl.id = 'challenge-choices'
for (const choice of challenge.choices) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'challenge-choice-btn'
btn.innerHTML = `
<div class="choice-icon-wrap">
<span class="choice-key">${choice.key}</span>
<div class="choice-icon"></div>
</div>
<div class="choice-text">
<span class="choice-label">${choice.label}</span>
<span class="choice-hint">${choice.hint}</span>
</div>
`
btn.addEventListener('click', () => resolveChallenge(challenge, choice.key))
choicesEl.appendChild(btn)
}
panel.appendChild(header)
panel.appendChild(desc)
panel.appendChild(choicesEl)
return panel
}
function renderResultBanner(
challenge: ChallengeDefinition,
state: GameState
): HTMLElement {
const result = state.save.currentRun.resolvedChallenges[challenge.id]
const choice = challenge.choices.find((c) => c.key === result?.choiceKey)
const banner = document.createElement('div')
banner.id = 'result-banner'
banner.className = `active ${result?.rating ?? ''}`
const ratingLabel =
result?.rating === 'good' ? 'GOOD' : result?.rating === 'ok' ? 'OK' : 'BAD'
let html = `
<strong class="rating-${result?.rating ?? ''}">${ratingLabel}</strong>
${choice?.feedback ?? ''}
`
if (result?.choiceKey !== challenge.recommended) {
const recommended = challenge.choices.find(
(c) => c.key === challenge.recommended
)
if (recommended) {
html += `
<div class="result-recommended">
<strong>Recommended:</strong> ${recommended.label}${recommended.hint}
</div>
`
}
}
if (challenge.docLink) {
html += `
<div style="margin-top:8px">
<a class="result-doc-link" href="${challenge.docLink.url}" target="_blank" rel="noopener">
${challenge.docLink.label}
</a>
</div>
`
}
banner.innerHTML = html
return banner
}
export { renderChallenge }

View File

@@ -1,73 +0,0 @@
import type { GameState } from '@/types'
import { buildNarrativeSummary } from '@/data/narrative'
import { resetForPrestige } from '@/engine/stateMachine'
import { persistSave } from '@/state/gameState'
import { canPrestige, prestige } from '@/state/prestige'
function renderPrestigeSection(state: GameState, summary: string): HTMLElement {
const section = document.createElement('div')
section.className = 'prestige-section'
if (canPrestige(state.save)) {
const teaser = document.createElement('p')
teaser.className = 'prestige-teaser'
teaser.textContent =
'The architecture breathes. Deeper layers await — more entangled, more instructive. Are you ready to descend?'
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'prestige-btn'
btn.textContent = 'Descend Deeper'
btn.addEventListener('click', () => {
const newSave = prestige(state.save, summary)
persistSave(newSave)
resetForPrestige(newSave)
})
section.appendChild(teaser)
section.appendChild(btn)
} else {
const maxLayer = document.createElement('p')
maxLayer.className = 'max-layer-text'
maxLayer.textContent = 'You have reached the deepest layer.'
section.appendChild(maxLayer)
}
return section
}
function renderEnding(state: GameState): void {
const main = document.getElementById('main')
if (!main) return
const run = state.save.currentRun
const summary = buildNarrativeSummary(run.resolvedChallenges)
const resolvedCount = Object.keys(run.resolvedChallenges).length
const conceptCount = run.conceptTags.length
main.innerHTML = ''
const title = document.createElement('h2')
title.className = 'ending-title'
title.textContent = 'State of the Codebase'
const narrative = document.createElement('p')
narrative.className = 'ending-narrative'
narrative.textContent = summary
const stats = document.createElement('div')
stats.className = 'ending-stats'
stats.innerHTML = `
<div class="stat"><span class="stat-label">Insight Earned</span><span class="stat-value">${run.insightEarned}</span></div>
<div class="stat"><span class="stat-label">Challenges Resolved</span><span class="stat-value">${resolvedCount}</span></div>
<div class="stat"><span class="stat-label">Concepts Learned</span><span class="stat-value">${conceptCount}</span></div>
<div class="stat"><span class="stat-label">Current Layer</span><span class="stat-value">${run.layer}</span></div>
`
main.appendChild(title)
main.appendChild(narrative)
main.appendChild(stats)
main.appendChild(renderPrestigeSection(state, summary))
}
export { renderEnding }

View File

@@ -1,43 +0,0 @@
import type { GameState } from '@/types'
import { challenges } from '@/data/challenges'
import { countResolvedChallenges } from '@/engine/navigation'
function createHud(): HTMLElement {
const hud = document.createElement('header')
hud.id = 'hud'
hud.innerHTML = `
<h1 id="game-title">Codebase Caverns</h1>
<div id="hud-right">
<div id="hud-insight">
<span class="hud-label">Insight</span>
<span id="insight-value">0</span>
</div>
<div id="hud-progress">
<span class="hud-label">Challenges</span>
<span id="progress-value">0/0</span>
</div>
<button id="toggle-map" type="button">Map [M]</button>
<button id="restart-btn" type="button">Restart</button>
</div>
`
return hud
}
function renderHud(state: GameState): void {
const insightEl = document.getElementById('insight-value')
const progressEl = document.getElementById('progress-value')
if (insightEl) {
const total =
state.save.persistent.totalInsight + state.save.currentRun.insightEarned
insightEl.textContent = String(total)
}
if (progressEl) {
const resolved = countResolvedChallenges(state.save)
const total = Object.keys(challenges).length
progressEl.textContent = `${resolved}/${total}`
}
}
export { createHud, renderHud }

View File

@@ -1,194 +0,0 @@
import type { GameState } from '@/types'
import { edges } from '@/data/graph'
import { rooms } from '@/data/rooms'
import { isChallengeResolved, isRoomDiscovered } from '@/engine/navigation'
import { enterRoom } from '@/engine/stateMachine'
import { canEnterRoom } from '@/state/tags'
interface NodePosition {
x: number
y: number
}
const NODE_POSITIONS: Record<string, NodePosition> = {
entry: { x: 300, y: 40 },
components: { x: 120, y: 140 },
stores: { x: 300, y: 140 },
services: { x: 480, y: 140 },
litegraph: { x: 60, y: 260 },
sidepanel: { x: 180, y: 260 },
ecs: { x: 300, y: 260 },
renderer: { x: 420, y: 260 },
composables: { x: 540, y: 260 },
subgraph: { x: 300, y: 370 }
}
const SVG_WIDTH = 600
const SVG_HEIGHT = 440
const NODE_RADIUS = 28
function getNodeState(
roomId: string,
state: GameState
): 'locked' | 'visited' | 'current' {
if (roomId === state.save.currentRun.currentRoom) return 'current'
if (isRoomDiscovered(roomId, state.save)) return 'visited'
return 'locked'
}
function createSvgElement<K extends keyof SVGElementTagNameMap>(
tag: K
): SVGElementTagNameMap[K] {
return document.createElementNS('http://www.w3.org/2000/svg', tag)
}
function buildEdges(): SVGGElement {
const g = createSvgElement('g')
const drawn = new Set<string>()
for (const edge of edges) {
const key = [edge.from, edge.to].sort().join('--')
if (drawn.has(key)) continue
drawn.add(key)
const from = NODE_POSITIONS[edge.from]
const to = NODE_POSITIONS[edge.to]
if (!from || !to) continue
const line = createSvgElement('line')
line.setAttribute('class', 'map-edge')
line.setAttribute('x1', String(from.x))
line.setAttribute('y1', String(from.y))
line.setAttribute('x2', String(to.x))
line.setAttribute('y2', String(to.y))
g.appendChild(line)
}
return g
}
function buildNode(
roomId: string,
state: GameState,
onSelect: (id: string) => void
): SVGGElement {
const room = rooms[roomId]
const pos = NODE_POSITIONS[roomId]
if (!room || !pos) return createSvgElement('g')
const nodeState = getNodeState(roomId, state)
const accessible = canEnterRoom(room, state.save)
const g = createSvgElement('g')
g.setAttribute('class', `map-node ${nodeState}`)
g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`)
if (accessible && nodeState !== 'locked') {
g.style.cursor = 'pointer'
g.addEventListener('click', () => onSelect(roomId))
}
const circle = createSvgElement('circle')
circle.setAttribute('r', String(NODE_RADIUS))
circle.setAttribute('cx', '0')
circle.setAttribute('cy', '0')
g.appendChild(circle)
const label = createSvgElement('text')
label.setAttribute('class', 'map-label')
label.setAttribute('text-anchor', 'middle')
label.setAttribute('dominant-baseline', 'middle')
label.setAttribute('y', '0')
label.textContent = room.id
g.appendChild(label)
const layerLabel = createSvgElement('text')
layerLabel.setAttribute('class', 'map-title')
layerLabel.setAttribute('text-anchor', 'middle')
layerLabel.setAttribute('y', String(NODE_RADIUS + 12))
layerLabel.textContent = room.layer
g.appendChild(layerLabel)
if (nodeState === 'locked') {
const lock = createSvgElement('text')
lock.setAttribute('class', 'map-lock')
lock.setAttribute('text-anchor', 'middle')
lock.setAttribute('dominant-baseline', 'middle')
lock.setAttribute('y', String(-NODE_RADIUS - 8))
lock.textContent = '🔒'
g.appendChild(lock)
} else if (room.challengeId) {
const resolved = isChallengeResolved(room.challengeId, state.save)
const badge = createSvgElement('text')
badge.setAttribute('class', 'map-badge')
badge.setAttribute('text-anchor', 'middle')
badge.setAttribute('dominant-baseline', 'middle')
badge.setAttribute('y', String(-NODE_RADIUS - 8))
badge.textContent = resolved ? '✓' : '?'
g.appendChild(badge)
}
return g
}
function buildSvg(
state: GameState,
onSelect: (id: string) => void
): SVGSVGElement {
const svg = createSvgElement('svg')
svg.setAttribute('viewBox', `0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`)
svg.setAttribute('width', '100%')
svg.setAttribute('style', 'max-height: 440px;')
svg.appendChild(buildEdges())
for (const roomId of Object.keys(rooms)) {
svg.appendChild(buildNode(roomId, state, onSelect))
}
return svg
}
function getDialog(): HTMLDialogElement | null {
return document.getElementById('map-dialog') as HTMLDialogElement | null
}
function createMapOverlay(): HTMLDialogElement {
const dialog = document.createElement('dialog')
dialog.id = 'map-dialog'
dialog.innerHTML = '<h3>Map</h3><div id="map-svg-container"></div>'
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close()
})
document.body.appendChild(dialog)
return dialog
}
function renderMap(state: GameState): void {
const container = document.getElementById('map-svg-container')
if (!container) return
container.innerHTML = ''
const svg = buildSvg(state, (roomId) => {
enterRoom(roomId)
getDialog()?.close()
})
container.appendChild(svg)
}
function toggleMap(): void {
const dialog = getDialog()
if (!dialog) return
if (dialog.open) {
dialog.close()
} else {
dialog.showModal()
}
}
export { createMapOverlay, renderMap, toggleMap }

View File

@@ -1,92 +0,0 @@
import type { GameState } from '@/types'
import { challenges } from '@/data/challenges'
import { countResolvedChallenges } from '@/engine/navigation'
import { showEnding } from '@/engine/stateMachine'
import { clearSave } from '@/state/gameState'
import { createHud, renderHud } from '@/ui/hud'
import { renderChallenge } from '@/ui/challengeView'
import { renderEnding } from '@/ui/endingView'
import { createMapOverlay, renderMap, toggleMap } from '@/ui/nodeMap'
import { createRoomView, renderRoom } from '@/ui/roomView'
import { createSidebar, renderSidebar } from '@/ui/sidebar'
function mountApp(): void {
const app = document.getElementById('app')
if (!app) throw new Error('Missing #app element')
app.appendChild(createHud())
app.appendChild(createRoomView())
app.appendChild(createSidebar())
createMapOverlay()
const toggleBtn = document.getElementById('toggle-map')
toggleBtn?.addEventListener('click', toggleMap)
const restartBtn = document.getElementById('restart-btn')
restartBtn?.addEventListener('click', () => {
clearSave()
location.reload()
})
document.addEventListener('keydown', handleKeydown)
}
function handleKeydown(e: KeyboardEvent): void {
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'M' || e.key === 'm') {
toggleMap()
return
}
if (e.key === 'Escape') {
const dialog = document.getElementById(
'map-dialog'
) as HTMLDialogElement | null
if (dialog?.open) dialog.close()
return
}
const numMatch = e.key.match(/^[1-9]$/)
if (numMatch) {
const index = parseInt(e.key, 10) - 1
const choices = document.querySelectorAll<HTMLButtonElement>('.choice-btn')
choices[index]?.click()
return
}
const letterMatch = e.key.match(/^[A-Ca-c]$/)
if (letterMatch) {
const key = e.key.toUpperCase()
const choices = document.querySelectorAll<HTMLButtonElement>(
'.challenge-choice-btn'
)
const match = Array.from(choices).find(
(btn) => btn.querySelector('.choice-key')?.textContent === key
)
match?.click()
}
}
function render(state: GameState): void {
renderHud(state)
renderSidebar(state)
renderMap(state)
if (state.phase === 'ending') {
renderEnding(state)
return
}
renderRoom(state)
renderChallenge(state)
const totalChallenges = Object.keys(challenges).length
const resolved = countResolvedChallenges(state.save)
if (resolved >= totalChallenges) {
showEnding()
}
}
export { mountApp, render }

View File

@@ -1,83 +0,0 @@
import type { GameState } from '@/types'
import { rooms } from '@/data/rooms'
import { isChallengeResolved } from '@/engine/navigation'
import { enterRoom } from '@/engine/stateMachine'
import { canEnterRoom } from '@/state/tags'
function createRoomView(): HTMLElement {
const main = document.createElement('main')
main.id = 'main'
main.innerHTML = `
<div id="room-header">
<h2 id="room-title"></h2>
<div id="room-layer"></div>
</div>
<div id="room-image" class="room-image placeholder"></div>
<p id="room-description"></p>
<div id="challenge-mount"></div>
<div id="room-choices"></div>
`
return main
}
function renderRoom(state: GameState): void {
const roomId = state.save.currentRun.currentRoom
const room = rooms[roomId]
if (!room) return
const titleEl = document.getElementById('room-title')
if (titleEl) titleEl.textContent = room.title
const layerEl = document.getElementById('room-layer')
if (layerEl) layerEl.textContent = room.layer
const imageEl = document.getElementById('room-image')
if (imageEl) {
if (room.imageUrl) {
imageEl.innerHTML = `<img src="${room.imageUrl}" alt="${room.title}" />`
imageEl.className = 'room-image'
} else {
imageEl.innerHTML = `<span>${room.layer}</span>`
imageEl.className = 'room-image placeholder'
}
}
const descEl = document.getElementById('room-description')
if (descEl) {
const challengeResolved =
room.challengeId !== undefined &&
isChallengeResolved(room.challengeId, state.save)
const showSolution = challengeResolved && room.solutionDescription !== ''
descEl.textContent = showSolution
? room.solutionDescription
: room.discoveryDescription
}
const choicesEl = document.getElementById('room-choices')
if (choicesEl) {
choicesEl.innerHTML = ''
room.connections.forEach((conn, index) => {
const targetRoom = rooms[conn.targetRoomId]
if (!targetRoom) return
const accessible = canEnterRoom(targetRoom, state.save)
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'choice-btn' + (accessible ? '' : ' locked')
btn.innerHTML = `
<span class="choice-key">${index + 1}</span>
<span class="choice-label">${conn.label}</span>
<span class="choice-hint">${accessible ? conn.hint : '🔒 ' + conn.hint}</span>
`
if (accessible) {
btn.addEventListener('click', () => enterRoom(conn.targetRoomId))
}
choicesEl.appendChild(btn)
})
}
}
export { createRoomView, renderRoom }

View File

@@ -1,37 +0,0 @@
import type { GameState } from '@/types'
function createSidebar(): HTMLElement {
const sidebar = document.createElement('aside')
sidebar.id = 'sidebar'
sidebar.innerHTML = `
<div id="concept-tags">
<h3 class="sidebar-header">Concept Tags</h3>
<div id="tags-list"></div>
</div>
<div id="artifacts-panel">
<h3 class="sidebar-header">Artifacts</h3>
<div id="artifacts-list"></div>
</div>
<div id="run-log">
<h3 class="sidebar-header">Log</h3>
<div id="log-entries"></div>
</div>
`
return sidebar
}
function renderSidebar(state: GameState): void {
const tagsList = document.getElementById('tags-list')
if (tagsList) {
tagsList.innerHTML = state.save.currentRun.conceptTags
.map((tag) => `<span class="tag-pill">${tag}</span>`)
.join('')
if (state.save.currentRun.conceptTags.length === 0) {
tagsList.innerHTML =
'<span class="empty-hint">None yet — explore and solve challenges</span>'
}
}
}
export { createSidebar, renderSidebar }

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "vite.config.ts", "scripts/**/*.ts"]
}

View File

@@ -1,26 +0,0 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
root: projectRoot,
base: './',
build: {
target: 'es2022',
outDir: 'dist',
assetsInlineLimit: 1_000_000,
cssCodeSplit: false,
rolldownOptions: {
output: {
inlineDynamicImports: true
}
}
},
resolve: {
alias: {
'@': path.resolve(projectRoot, 'src')
}
}
})

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 +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,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

@@ -5,5 +5,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.ts"]
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -30,24 +30,6 @@ browser_tests/
└── tests/ - Test files (*.spec.ts)
```
## 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 |

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,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,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'
@@ -42,7 +40,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
import type { NodeReference } from './utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
dotenv.config()
class ComfyPropertiesPanel {
readonly root: Locator
@@ -57,7 +55,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
@@ -81,11 +78,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
@@ -200,7 +192,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 */
@@ -247,7 +238,6 @@ export class ComfyPage {
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)
}
@@ -462,13 +452,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

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

@@ -3,28 +3,17 @@ 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)
}
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
@@ -35,6 +24,42 @@ export class AppModeHelper {
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
@@ -93,4 +118,84 @@ export class AppModeHelper {
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @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()
}
/**
* 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 renameBuilderInputViaMenu(title: string, newName: string) {
const menu = this.getBuilderInputItemMenu(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 to trigger
* inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.filter({ hasText: title })
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()
}
}

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

@@ -169,39 +169,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

@@ -25,15 +25,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)
@@ -45,8 +43,7 @@ export class DragDropHelper {
fileType?: string
buffer?: Uint8Array | number[]
url?: string
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
} = { dropPosition }
if (fileName) {
const filePath = this.assetPath(fileName)
@@ -118,17 +115,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)
@@ -159,10 +154,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

@@ -23,7 +23,6 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -191,7 +190,6 @@ 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,

View File

@@ -1,11 +1,10 @@
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'
@@ -414,138 +413,4 @@ export class SubgraphHelper {
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,6 +1,5 @@
import { readFileSync } from 'fs'
import type { AppMode } from '../../../src/composables/useAppMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
@@ -105,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

@@ -20,12 +20,7 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-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,8 +29,6 @@ export const TestIds = {
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',
@@ -51,8 +44,7 @@ export const TestIds = {
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'
@@ -70,22 +62,15 @@ 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'
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'

View File

@@ -392,11 +392,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,

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