Compare commits

..

51 Commits

Author SHA1 Message Date
DrJKL
bef8133a53 fix(adventure): remove dead code flagged by knip
Delete unused dom.ts, remove unexported functions/types, inline
hasAllPrerequisites into its sole remaining caller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:45:06 -07:00
DrJKL
9f7646309c fix(adventure): remove dead CSS and fix empty-ratings crash
Remove unused styles from pre-SVG map, stat bars, sidebar sections,
welcome panel, inventory grid, artifacts, decisions, and achievements.
Rename #choices to #room-choices. Guard sectionTone against empty ratings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:25 -07:00
Alexander Brown
28797da9c0 fix(adventure): grant concept tags on any challenge answer
Resolving a challenge with an OK or BAD answer previously granted no
concept tags, making rooms with those tags as prerequisites permanently
inaccessible. Now tags are granted at the challenge level on any
resolution. Per-choice tags remain as bonus tracking for GOOD answers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:25 -07:00
Alexander Brown
98e3742ada oxfmt 2026-03-28 23:01:25 -07:00
Alexander Brown
e2b93bd0e5 feat(adventure): add single-file build pipeline 2026-03-28 23:01:25 -07:00
Alexander Brown
997f4995dc feat(adventure): add renderer orchestrator and bootstrap game
Wires all UI modules together in renderer.ts with mountApp/render,
adds keyboard shortcuts (M, Escape, 1-9, A-C), and replaces the
placeholder main.ts with full bootstrap (load save, mount, subscribe,
enter room).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:01:25 -07:00
Alexander Brown
0d71e6757a feat(adventure): add ending view with narrative summary and prestige 2026-03-28 23:01:25 -07:00
Alexander Brown
9b5c24b0ed feat(adventure): add SVG node-graph map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:01:25 -07:00
Alexander Brown
418ae5b767 feat(adventure): add room view and challenge view UI 2026-03-28 23:01:25 -07:00
Alexander Brown
98c1ffc5de feat(adventure): add DOM helpers, HUD, and sidebar UI 2026-03-28 23:01:25 -07:00
Alexander Brown
a8670fed6e feat(adventure): add CSS theme, layout, and component styles
Extracts v1 visual design from adventure.html into 8 modular CSS files
(theme, layout, hud, room, challenge, sidebar, map, animations) and
imports them in main.ts.
2026-03-28 23:01:25 -07:00
Alexander Brown
5d3b074f9d feat(adventure): add room data, challenges, graph, and narrative system
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:25 -07:00
Alexander Brown
62a34952f1 feat(adventure): add state machine and navigation engine 2026-03-28 23:01:24 -07:00
Alexander Brown
fcb7d914f3 feat(adventure): add state management, tags, and prestige
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
5954b799dd feat(adventure): add core type definitions 2026-03-28 23:01:24 -07:00
Alexander Brown
440b3280b6 fix: add architecture-adventure workspace to knip config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
8aef477ce7 feat(adventure): scaffold Vite project for Codebase Caverns v2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
c33ab2f155 Add .superpowers to gitignore 2026-03-28 23:01:24 -07:00
Alexander Brown
63022511a2 docs: clarify ECS instance scoping, subgraph recursion, layout decoupling, and phase gates
Amp-Thread-ID: https://ampcode.com/threads/T-019d2b99-d068-77aa-be88-5846f1e74e54
Co-authored-by: Amp <amp@ampcode.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
9a2eb88d6c docs: add Command Forge room and extension migration to adventure game
Convert the Side Panel lore room into "The Command Forge" with a new
challenge teaching the Command Pattern / World API layering from the
ecs-world-command-api.md addendum. Enrich the Services room with
extension migration patterns from the updated migration plan.

- 8 challenges -> 9 (new: The Mutation Gateway)
- 13 artifacts -> 16 (CommandExecutor, Command Interface, Extension Migration Guide)
- Generate pixel art icons for new artifacts and choice buttons
- Update room image to command forge theme
- Recalculate speedrun route and spaghetti singularity path
- Update walkthrough with new challenge solutions and stats

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
bb5713d4c3 fix: address remaining ECS ADR review docs feedback
Amp-Thread-ID: https://ampcode.com/threads/T-019d275e-a5aa-7057-8699-f403397f0df2
Co-authored-by: Amp <amp@ampcode.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
ddf0256695 docs: add World command API addendum and cross-reference from ADR 0008
- Add ecs-world-command-api.md showing how imperative World calls translate
  to serializable commands executed by systems
- Add 'Relationship to ADR 0003' section to ADR 0008 clarifying the
  complementary layering: Commands (intent) → Systems (handlers) → World (store)

Amp-Thread-ID: https://ampcode.com/threads/T-019d270c-1975-7590-aaae-551eb71b26ff
Co-authored-by: Amp <amp@ampcode.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
7ff4938ed6 feat: add 5 missing adventure game icons
Generate subgraph challenge choice icons (a/b/c) and artifact icons
(subgraph-structure, typed-contracts) to complete the icon set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
16b262cbb5 More friendly styles, use the whole screen. 2026-03-28 23:01:24 -07:00
DrJKL
b55a5582fd feat: add Subgraph Depths room, improve accessibility and mobile layout
- Add "The Subgraph Depths" room with Widget Promotion Decision
  challenge reflecting the new subgraph boundaries and graph
  unification design from ADR0008
- Update ECS room description to reference 6 entity kinds, flat
  World with graphScope tags, and subgraph-as-component model
- Update Component Gallery challenge to reference graph unification
- Fix WCAG contrast: --muted #8b949e -> #9ea7b0 (AAA on surface),
  .dec-slot.empty text from invisible --border to --muted
- Fix mobile HUD: compact layout with single-letter stat labels,
  reduced padding, smaller buttons
- Fix mobile sidebar: full-width via align-self:stretch, was
  constrained by align-self:flex-start from desktop sticky mode
- Fix map card structure: challenge badge in flex row header for
  consistent alignment across cards with/without challenges
- Update walkthrough: 10 rooms, 8 challenges, 13 artifacts,
  new optimal route, updated Spaghetti path and playthrough count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
GitHub Action
0d19d8646c [automated] Apply ESLint and Oxfmt fixes 2026-03-28 23:01:24 -07:00
DrJKL
8449a496ca docs: add subgraph boundaries doc, unify graph model across ECS architecture
- New companion doc: subgraph-boundaries-and-promotion.md
  - Graph model unification: all graphs are isomorphic, 7→6 entity kinds
  - Typed boundary contracts replace virtual nodes and magic IDs
  - Widget promotion as open decision (connections-only vs simplified)
  - Serialization boundary with indefinite backward-compatible loading
- Update ADR 0008: remove SubgraphEntityId, add GraphId scope, flat World
- Update ecs-target-architecture: World diagram, Entity IDs, problem map
- Update ecs-migration-plan: Phase 1a/1c types and World interface
- Update ecs-lifecycle-scenarios: pack/unpack use graphScope re-parenting
- Update proto-ecs-stores and entity-interactions: annotate subgraph rows

Amp-Thread-ID: https://ampcode.com/threads/T-019d2311-3707-746a-ae9c-65e6f0d67f3e
Co-authored-by: Amp <amp@ampcode.com>
2026-03-28 23:01:24 -07:00
DrJKL
b6d8836d14 fix: strip leading whitespace from multi-line mermaid labels in architecture docs
Amp-Thread-ID: https://ampcode.com/threads/T-019d2303-d3a2-7659-9beb-24fc8b6813ee
Co-authored-by: Amp <amp@ampcode.com>
2026-03-28 23:01:24 -07:00
DrJKL
5dea2d0f1b fix: tighten ECS architecture docs and migration gates
Amp-Thread-ID: https://ampcode.com/threads/T-019d22e0-5d0a-767a-92c5-84ad5f2a4d00
Co-authored-by: Amp <amp@ampcode.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
0505bda1ce fix: replace blanket CSS reset with modern reset, fix text jiggle
- Modern CSS reset (Comeau/Bell style) replaces * { margin: 0; padding: 0 }
- Restore dialog margin: auto for proper centering
- Remove translateY from room transitions to prevent text jiggle
- Opacity-only fade for clean room changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
c78ad2f2c6 fix: correct Spaghetti Singularity walkthrough path
The previous guide suggested picking all bad-rated answers, but
Litegraph A (Rewrite, Debt -20) and Services B (Big bang, Debt -10)
actually lower debt, making it impossible to reach Debt >= 70.

Replaced with a proven path using debt-adding choices (C/C/C/C/C/B/B)
that reaches Debt 95. Added warning about the counterintuitive trap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
fcdb1237d2 fix: align map arrows and normalize challenge boxes in walkthrough
Map: all pipe bodies now align with arrow heads at same column.
Challenge boxes: all lines normalized to 72 chars wide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:24 -07:00
Alexander Brown
3428466eff fix: normalize ending box widths to 72 chars in walkthrough
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
f61bde18dc fix: align ASCII art in walkthrough (map, ending boxes, building)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
eb4be6052e docs: add GameFAQs-style walkthrough with ASCII art and pro tips
Complete spoiler guide: all 7 challenge solutions, optimal speedrun
route, all 4 endings, achievement unlock strategies, artifact
checklist, and architecture reading order recommendations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
0dca2d5b05 refactor: convert map and ending overlays to native dialog elements
- Map uses <dialog> with showModal()/close(), click-outside-to-close
- Ending uses <dialog> with Escape blocked for real endings, allowed for previews
- CSS uses @starting-style for smooth open/close transitions
- Removes manual backdrop div and z-index management
- Native focus trapping and accessibility from <dialog>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
caf98def1b fix: previewing an ending mid-game closes without resetting state
Button shows "Close" for previews, "Play Again" for real endings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
8319e74f7b feat: click unlocked ending badges to review their ending screen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
6e06280642 feat: localStorage persistence, restart button, and ending achievements
- Game state persists across page refreshes via localStorage
- Restart button in HUD resets game without losing achievements
- Endings sidebar tracks which of the 4 endings have been unlocked
- Achievement icons generated for each ending (locked/unlocked states)
- Achievements persist across runs in separate storage key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
716629adbc fix: add missing room variable in renderRoom, improve room transitions
Split render into render() (animation) and renderRoom() (content swap).
Fade-out slides up, fade-in slides down for a smoother feel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
8779a1e4f8 feat: add Open Graph and Twitter Card meta tags for rich previews
Uses entry room pixel art as preview image via raw.githubusercontent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
4a5e409d7f feat: make Log section collapsible with smooth animation
Uses native <details>/<summary> with interpolate-size for smooth
height transition. Chevron indicator rotates on open/close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
70c3f88f1f feat: preload images for adjacent rooms to prevent flash on navigate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
7db077534e feat: add inline emoji favicon for adventure game
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
0006815ebf feat: challenge choice icons and decisions tracker in sidebar
- 20 choice icons generated (one per A/B/C option across 7 challenges)
- Challenge buttons now card-style columns with icon, key badge overlay, and text
- Decisions sidebar section shows icon grid of choices made so far
- Slots colored by rating (green/yellow/red), hover label shows details
- Unified icon generation script handles both artifact and choice prompts
- Choice icon prompt reference JSON

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
629513579e feat: graphical inventory with generated pixel art icons
- 11 artifact icons generated via Z-Image Turbo (128x128)
- Inventory displays as icon grid with hover label strip
- Artifacts in rooms show icon thumbnails alongside name/type
- Icon generation script with skip-existing logic
- Prompt reference JSON for all artifact icons
- Fix inventory not resetting on Play Again
- Add user-select: none to presentational elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
06739fc4b0 feat: add educational callouts, scorecard, GitHub links, and fix layout
- Link code references to actual files on GitHub main branch
- Show recommended answer + architecture doc link after each challenge
- End-game scorecard recaps all 7 decisions vs. recommended approach
- Challenge progress counter (0/7) in HUD
- Map is now a centered overlay with backdrop (Escape to close)
- Page scrolls naturally; sidebar sticks; no more clipped content
- Subtle fade transition between rooms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
79a2f577c0 feat: add decision system, stats, generated images to architecture adventure
- 7 architectural challenge encounters with branching choices and stat effects
- 4 tracked stats: Technical Debt, Code Quality, Team Morale, ECS Migration
- 4 endings based on accumulated decisions (Spaghetti Singularity through ECS Enlightenment)
- 9 pixel art room images generated via Z-Image Turbo (1152x640, no LoRA)
- Image generation script (generate-images.py) and prompt reference JSON
- Keyboard navigation for challenges (A/B/C) and rooms (1/2/3)
- Map badges showing pending/completed challenge status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Claude
bed2c2fdab feat: add architecture adventure game skeleton
Interactive HTML game for exploring the ComfyUI frontend architecture.
Includes 9 rooms covering entry point, components, stores, services,
litegraph engine, ECS, renderer, composables, and side panel layers.
Keyboard navigation, map view, inventory, and activity log.

https://claude.ai/code/session_01PivAjqNcLVzZVF3C5irFdW
2026-03-28 23:01:23 -07:00
Alexander Brown
e6901a32a3 docs: draft ECS component interfaces and World type
Example interfaces for all 7 entity kinds (Node, Link, Slot, Widget,
Reroute, Group, Subgraph), branded entity ID types with cast helpers,
and a Map-backed World implementation. Reuses existing litegraph types
(Point, Size, INodeFlags, ISlotType, etc.) for migration compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:23 -07:00
Alexander Brown
97c61eeff3 docs: draft ECS component interfaces and World type
Example interfaces for all 7 entity kinds (Node, Link, Slot, Widget,
Reroute, Group, Subgraph), branded entity ID types with cast helpers,
and a Map-backed World implementation. Reuses existing litegraph types
(Point, Size, INodeFlags, ISlotType, etc.) for migration compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:01:22 -07:00
2270 changed files with 42374 additions and 213718 deletions

View File

@@ -1,86 +1,9 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
"permissions": {
"allow": [
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
"Bash(pnpx vitest run \"draftCacheV2.property\")",
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
]
}
}

View File

@@ -1,173 +0,0 @@
---
name: add-model-page
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
---
# add-model-page
add, update, or remove model pages in the ComfyUI website.
## Trigger phrases
- `Add a model page for <model-name>`
- `Update the model page for <model-name>`
- `Remove <model-name> from model pages`
## Phase 1 — Parse the request
Extract:
- **action**: `add` | `update` | `remove`
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
Example: `flux1_dev.safetensors``flux1-dev`
## Architecture overview
Models come from two sources merged at build time:
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
To regenerate the JSON from workflow_templates:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
```
This writes `apps/website/src/config/generated-models.json` directly.
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
---
## Phase 2 — Gather model data (ADD / UPDATE)
Run the generator to get fresh data, then find the model:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
apps/website/src/config/generated-models.json
```
The JSON fields are:
- `slug` — URL slug
- `name` — exact filename or display name for partner nodes
- `huggingFaceUrl` — download URL (empty for partner nodes)
- `directory``diffusion_models` | `loras` | … | `partner_nodes`
- `workflowCount` — integer
- `displayName` — human-readable name
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
`generate-models.ts` and re-run. Otherwise tell the user.
---
## Phase 3 — Check for existing entry
```bash
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
apps/website/src/config/generated-models.json
```
- Match found + action is `add` → switch to UPDATE flow automatically
- No match + action is `update` → stop and tell the user
---
## Phase 4A — ADD: new partner/API model not in workflow_templates
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
`apps/website/scripts/generate-models.ts`:
```typescript
mymodel: { name: 'My Model', slug: 'my-model' },
```
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
in `generated-models.json` automatically.
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
```typescript
'my-model': {
docsUrl: 'https://docs.comfy.org/tutorials/...',
blogUrl: 'https://blog.comfy.org/...',
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
featured: true
}
```
No changes to `models.ts` or `translations.ts` are needed.
---
## Phase 4B — UPDATE: edit existing entry
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
featured). For `displayName` or `directory` changes, edit the entry directly in
`generated-models.json` (until the next generator run would overwrite it — then
fix the source in `generate-models.ts`).
---
## Phase 4C — REMOVE: delete entry
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
pointing to the replacement). No translation file changes needed.
---
## Phase 5 — Verify TypeScript
```bash
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
```
Fix any type errors before proceeding. Common issues:
- `ModelDirectory` type not matching a new `directory` value — add it to the union
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
---
## Phase 6 — Create PR
```bash
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
git checkout -b $BRANCH
git add apps/website/src/config/generated-models.json \
apps/website/scripts/generate-models.ts \
apps/website/src/config/model-metadata.ts
git commit -m "feat(models): add model page for MODEL-SLUG"
git push -u origin $BRANCH
gh pr create \
--title "Add model page: MODEL-SLUG" \
--body "$(cat <<'EOF'
Adds a new model page entry for MODEL-SLUG.
## Changes
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
EOF
)"
```
For UPDATE use branch `update-model-page-MODEL-SLUG`.
For REMOVE use `remove-model-page-MODEL-SLUG`.
---
## Error states
| Situation | Response |
| ------------------------------- | ---------------------------------------------------------------- |
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
| Slug already exists (add) | Switch to update flow automatically |
| Slug not found (update/remove) | Stop and ask user to confirm |
| Typecheck fails | Fix the error before pushing |

View File

@@ -9,18 +9,12 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## Quick Start
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.claude/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
5. **Analyze**Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
5. **Verify**After each wave, verify branch integrity before proceeding
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
## System Context
@@ -43,29 +37,16 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
**Critical: Match PRs to the correct target branches.**
| Branch prefix | Scope | Example |
| ------------- | ------------------------------ | ------------------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
| Branch prefix | Scope | Example |
| ------------- | ------------------------------ | ----------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
### What Goes Where
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
**Both core + cloud:**
- **App mode** PRs — app mode is NOT cloud-only
- **Firebase auth** PRs — Firebase auth is on core for API nodes
- **Payment redirect** PRs — payment infrastructure shared
- **Bug fixes** touching shared components
**Cloud-only (skip for core):**
- Team workspaces
- Cloud queue virtualization
- Hide API key login
- Cloud-specific UI behind cloud feature flags
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed.
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
## ⚠️ Gotchas (Learn from Past Sessions)
@@ -86,61 +67,6 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
### Accept-Theirs Can Produce Broken Hybrids
When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa.
**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries.
**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely.
### Cherry-Picks Can Reference Missing Dependencies
When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken.
**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR.
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick.
**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck.
### Use `--no-verify` for Worktree Pushes
Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees.
### Automation Success Varies Wildly by Branch
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
```bash
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
```
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
### Backport-Only Compatibility Shims
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
- `node.onXxx` callback assignments
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
- Public exports from `src/lib/litegraph/`
- Type changes affecting `litegraph-augmentation.d.ts`
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
@@ -151,8 +77,6 @@ When a PR's _mechanism_ relies on changes upstream that aren't on the older bran
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs |
| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after |
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
@@ -179,27 +103,7 @@ Skip these without discussion:
- **Test-only / lint rule changes** — Not user-facing
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
### Path Pre-Filter (run BEFORE reading PR bodies)
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
| Path prefix | Bucket | Reason |
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
| `.github/workflows/` only | SKIP | CI/release infra |
| `packages/design-system/` only | SKIP | Design tokens, not core |
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
| `.claude/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
| `*.stories.ts` only | SKIP | Storybook only |
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
## Wave Verification
@@ -218,18 +122,6 @@ 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.
### Fix PRs Are Normal
Expect to create 1 fix PR per branch after verification. Common issues:
1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path`
2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit
3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface
4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports
5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function
Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`.
### 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.
@@ -243,43 +135,6 @@ Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuo
- Reserve session-style bulk backporting for catching up after gaps
- When a release branch is created, immediately start the continuous process
## Interactive Approval Flow
After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation.
### Batch Presentation
Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format:
```
# | PR | Title | Target | Rec | Context
----+--------+------------------------------------------+---------------+------+--------
1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning.
2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch.
```
Each row includes:
- PR number and title
- Target branches
- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning
- 2-3 sentence context: what the PR does, why it matters (or doesn't)
### Human Response Format
- `Y` — approve for backport
- `N` — skip
- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks)
- Any freeform question or comment triggers discussion before moving on
- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`)
### Rules
- ALL candidates are reviewed, not just MUST items
- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision
- When human asks a question about a PR, answer with context and recommendation, then wait for their decision
- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N
## Quick Reference
### Label-Driven Automation (default path)
@@ -295,96 +150,13 @@ gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
```bash
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
cd /tmp/backport-$BRANCH
# For each PR:
git fetch origin $BRANCH
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
git cherry-pick -m 1 $MERGE_SHA
# Resolve conflicts (see Conflict Triage)
git push origin backport-$PR-to-$BRANCH --no-verify
gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \
--title "[backport $BRANCH] $TITLE (#$PR)" \
--body "Backport of #$PR. [conflict notes]"
gh pr merge $NEW_PR --squash --admin
sleep 25
# Resolve conflicts, push, create PR, merge
```
### Efficient Batch: Test-Then-Resolve Pattern
When many PRs need manual cherry-pick (e.g., cloud branches), test all first:
```bash
cd /tmp/backport-$BRANCH
for pr in "${ORDER[@]}"; do
git checkout -b test-$pr origin/$BRANCH
if git cherry-pick -m 1 $SHA 2>/dev/null; then
echo "CLEAN: $pr"
else
echo "CONFLICT: $pr"
git cherry-pick --abort
fi
git checkout --detach HEAD
git branch -D test-$pr
done
```
Then process clean PRs in a batch loop, conflicts individually.
### PR Title Convention
```
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
```
## Final Deliverables (Slack-Compatible)
After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only.
### 1. Author Accountability Report
File: `backport-author-accountability.md`
Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling.
```
Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors
- author-login
- #1234 fix: short title
- #5678 fix: another title
- other-author
- #9012 fix: some other fix
```
Authors sorted alphabetically, 4-space indent for nested items.
### 2. Slack Status Update
File: `slack-status-update.md`
A shareable summary of the session. Structure:
```
Backport session complete -- YYYY-MM-DD
[1-sentence summary: N PRs backported to which branches. All pass typecheck.]
Branches updated:
- core/X.XX: N PRs + N fix PRs (N auto, N manual)
- cloud/X.XX: N PRs + N fix PRs (N auto, N manual)
- ...
N total PRs created and merged (N backports + N fix PRs).
Notable fixes included:
- [category]: [list of fixes]
- ...
Conflict patterns encountered:
- [pattern and how it was resolved]
- ...
N authors had PRs backported. See author accountability list for details.
```
No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack.

View File

@@ -23,10 +23,10 @@ For SHOULD items with conflicts: if conflict resolution requires more than trivi
**Before categorizing, filter by branch scope:**
| Target branch | Skip if PR is... |
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. |
| `cloud/*` | Local-only features not present on cloud branch |
| Target branch | Skip if PR is... |
| ------------- | ------------------------------------------------------------------- |
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
| `cloud/*` | Local-only features not present on cloud branch |
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
@@ -39,89 +39,6 @@ Check before backporting — these don't exist on older branches:
- **App builder** — check per branch
- **appModeStore.ts** — not on core/1.40
### Verify Target File Existence (Run Before Cherry-Pick)
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
```bash
# For each file the PR changes
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
echo "MISSING on $TARGET: $f"
fi
done
```
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
## Tiered Triage (Recommended for 30+ Candidates)
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
### Tier 1 — Core Editor Must-Haves
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
Indicators:
- `fix:` prefix and the bug is reproducible on the target branch
- Crash guards, runtime null checks, race-condition fixes
- Data-loss bugs (state not persisted, duplicates, drops)
- Security hardening (CSRF, XSS, auth)
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
- Subgraph correctness fixes
- Public-API extension callback fixes
Recommend `Y` to user.
### Tier 2 — Cloud-Distribution Only
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
Indicators:
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
- PR description mentions cloud staging issues
- Fix gated behind cloud feature flags
Default: ask the cloud release rotation owner. If unsure, defer.
### Tier 3 — Skip
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
Indicators:
- All changes in test files even if the PR touched `src/` test files
- Storybook stories only
- Lint config / lint rule additions
- Documentation comments
- Internal refactors with no behavior change
### Presentation Format
When showing tier results to the user, format as:
```text
Tier 1 (N PRs) — strong backport candidates
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
- #10849 fix: store promoted widget values per SubgraphNode instance
Why: Multiple instances overwriting each other's promoted widget values — data loss
Tier 2 (N PRs) — cloud-distribution release rotation should decide
- #11636 fix: enable Chrome password autofill on signup form
- ...
Tier 3 (N PRs) — skip recommended
- #11586 fix: website polish (apps/website/ only)
- ...
```
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
## Dep Refresh PRs
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
@@ -144,6 +61,8 @@ done
## Human Review Checkpoint
Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation.
Present decisions.md before execution. Include:
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.
1. All MUST/SHOULD/SKIP categorizations with rationale
2. Questions for human (feature existence, scope, deps)
3. Estimated effort per branch

View File

@@ -1,11 +1,5 @@
# Discovery — Candidate Collection
**Run all sources, then reconcile.** No single source is authoritative:
- Slack bot may flag PRs that have already been backported (false positive)
- Git gap may include PRs that don't need backport (test-only, design-system, website)
- Bot can also miss PRs that landed without the right labels
## Source 1: Slack Backport-Checker Bot
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
@@ -42,43 +36,7 @@ gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.merge
gh pr view $PR --json files --jq '.files[].path'
```
## Source 4: Already-Backported PRs (cross-reference)
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
```bash
# Get all original PR numbers already backported to TARGET since the last release tag
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
| grep -oE "[0-9]+" \
| sort -un > /tmp/already-backported.txt
```
Subtract this list from your candidates.
## Reconciliation Workflow
```bash
# 1. Slack bot list (parse from export)
# /tmp/bot-flagged.txt — one PR# per line, sorted
# 2. Git gap fix/perf only
MB=$(git merge-base origin/main origin/$TARGET)
git log --format="%h|%s" $MB..origin/main \
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
| sort -un > /tmp/gap-fixes.txt
# 3. Already backported (Source 4 above)
# 4. Candidates = (gap-fixes bot-flagged) already-backported
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
```
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
## Output: candidate_list.md
Table per target branch:
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |
| PR# | Title | Category | Flagged by Bot? | Decision |

View File

@@ -6,43 +6,6 @@
2. Medium gap next (quick win)
3. Largest gap last (main effort)
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
cd /tmp/dryrun-TARGET
CLEAN=()
CONFLICT=()
for pr in "${CANDIDATES[@]}"; do
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
if git cherry-pick -m 1 $SHA 2>/dev/null; then
CLEAN+=($pr)
else
CONFLICT+=($pr)
git cherry-pick --abort
fi
git checkout --detach HEAD 2>/dev/null
git branch -D dryrun-$pr 2>/dev/null
done
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
cd -
git worktree remove /tmp/dryrun-TARGET --force
```
Use the result to:
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
## Step 1: Label-Driven Automation (Batch)
```bash
@@ -110,55 +73,14 @@ for PR in ${CONFLICT_PRS[@]}; do
git cherry-pick -m 1 $MERGE_SHA
# If conflict — NEVER skip based on file count alone!
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
# See SKILL.md Conflict Triage table for resolution per type.
# For component rewrites (4+ markers in a .vue file, library migration):
# DO NOT use accept-theirs regex — it produces broken hybrids.
# Instead, use the complete file from the merge commit:
# git show $MERGE_SHA:path/to/file > path/to/file
# For simple content conflicts, accept theirs:
# python3 -c "import re; ..."
# Resolve all conflicts, then:
git add .
GIT_EDITOR=true git cherry-pick --continue
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
# If the conflict resolution touched any of these surfaces, consult oracle
# BEFORE pushing. A bad shim is worse than no fix:
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
# - Public exports from src/lib/litegraph/
# - Type changes in litegraph-augmentation.d.ts
# If a public callback's signature/contract changed: add a backport-only
# compatibility shim that preserves the OLD contract while keeping the
# new fix. Document it in the commit body under
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
# ───────────────────────────────────────────────────────────────────────
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
pnpm typecheck
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
if [ ${#TEST_FILES[@]} -gt 0 ]; then
pnpm test:unit -- run "${TEST_FILES[@]}"
else
echo "No changed test files — skipping targeted unit tests"
fi
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
if [ ${#CODE_FILES[@]} -gt 0 ]; then
pnpm exec eslint "${CODE_FILES[@]}"
pnpm exec oxfmt --check "${CODE_FILES[@]}"
else
echo "No changed ts/vue files — skipping targeted lint/format"
fi
git push origin backport-$PR-to-TARGET --no-verify
git push origin backport-$PR-to-TARGET
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+$')
@@ -192,30 +114,7 @@ source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm tes
git worktree remove /tmp/verify-TARGET --force
```
If verification fails, **do not skip** — create a fix PR:
```bash
# Stay in the verify worktree
git checkout -b fix-backport-TARGET origin/TARGET_BRANCH
# Common fixes:
# 1. Component rewrite hybrids: overwrite with merge commit version
git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue
# 2. Missing dependency files
git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts
# 3. Missing type properties: edit the interface
# 4. Unused imports: delete the import lines
git add -A
git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET"
git push origin fix-backport-TARGET --no-verify
gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..."
gh pr merge $PR --squash --admin
```
Do not proceed to the next branch until typecheck passes.
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
## Conflict Resolution Patterns
@@ -243,35 +142,7 @@ git rm $FILE
git checkout --theirs $FILE && git add $FILE
```
### 4. Component Rewrites (DO NOT accept-theirs)
When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces
a broken hybrid with mismatched template/script sections.
```bash
# Use the complete correct file from the merge commit instead:
git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue
git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue
git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue
```
**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component
libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side.
### 5. Missing Dependencies After Cherry-Pick
Cherry-picks can succeed but leave the branch broken because the PR's code on main
references composables/components introduced by an earlier PR.
```bash
# Add the missing file from the merge commit:
git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts
git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue
```
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly.
### 6. Locale Files
### 4. Locale Files
Usually adding new i18n keys — accept theirs, validate JSON:
@@ -305,17 +176,8 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
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.
11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only.
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.
13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead.
14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit.
15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure.
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
## CI Failure Triage
@@ -341,40 +203,3 @@ Common failure categories:
| 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.
## PR Body Template (Manual Cherry-Picks)
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
```markdown
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
Cherry-picked from upstream merge commit `SHORT_SHA`.
## Why
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
## Conflict resolution
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
- [...]
## Backport-only compatibility fix (if applicable)
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
## Validation
- `pnpm typecheck`
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
- `pnpm exec eslint <changed files>` ✅ (0 errors)
- `pnpm exec oxfmt --check` ✅ (clean)
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
Original PR: #ORIG / Original commit: `FULL_SHA`
```
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"

View File

@@ -2,25 +2,26 @@
## During Execution
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | ------ | ----------- | ------- |
| #XXXX | Title | merged | #YYYY | Details |
| PR# | Title | CI Status | Status | Backport PR | Notes |
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
Track verification results per wave within execution-log.md:
Track verification results per wave:
```markdown
Wave N Verification -- TARGET_BRANCH
## Wave N Verification TARGET_BRANCH
- PRs merged: #A, #B, #C
- Typecheck: pass / fail
- Fix PR: #YYYY (if needed)
- Typecheck: ✅ Pass / ❌ Fail
- Unit tests: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
## Session Report Template
@@ -62,42 +63,40 @@ Wave N Verification -- TARGET_BRANCH
- Feature branches that need tracking for future sessions?
```
## Final Deliverables
## Final Deliverable: Visual Summary
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
### 1. execution-log.md (internal)
```mermaid
graph TD
subgraph branch1["☁️ cloud/X.XX — N PRs"]
C1["#XXXX title"]
C2["#XXXX title"]
end
Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack.
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
M1["#XXXX title"]
end
### 2. backport-author-accountability.md (Slack-compatible)
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
S1["#XXXX-#XXXX N auto-merged"]
S2["#XXXX-#XXXX N manual picks"]
end
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
```
### 3. slack-status-update.md (Slack-compatible)
See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count.
## Slack Formatting Rules
Both shareable files (author accountability + status update) must follow these rules:
- No emojis (no checkmarks, no arrows, no icons)
- No markdown tables (use plain lists with dashes)
- No headers (no # or ##)
- No bold (\*_) or italic (_)
- No inline code backticks
- Use -- instead of em dash
- Use plain dashes (-) for lists with 4-space indent for nesting
- Line breaks between sections for readability
These files should paste directly into a Slack message and look clean.
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
## Files to Track
All in `~/temp/backport-session/`:
- `candidate_list.md` — all candidates per branch
- `decisions.md` — MUST/SHOULD/SKIP with rationale
- `wave-plan.md` — execution order
- `execution-log.md` — real-time status
- `backport-session-report.md` — final summary
- `execution-plan.md` -- approved PRs with merge SHAs (input)
- `execution-log.md` -- real-time status with per-branch tables (internal)
- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible)
- `slack-status-update.md` -- session summary (Slack-compatible)
All in `~/temp/backport-session/`.

View File

@@ -1,246 +0,0 @@
---
name: hardening-flaky-e2e-tests
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
---
# Hardening Flaky E2E Tests
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
## Workflow
### 1. Gather CI Evidence
```bash
gh run list --workflow=ci-test.yaml --limit=5
gh run download <run-id> -n playwright-report
```
- Open `report.json` and search for `"status": "flaky"` entries.
- Collect file paths, test titles, and error messages.
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
- Use `error-context.md`, traces, and page snapshots before editing code.
- Pull the newest run after each push instead of assuming the flaky set is unchanged.
### 2. Classify the Flake
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
| # | Pattern | Signature in Code | Fix |
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
### 3. Apply the Transform
#### Rule: Choose the Smallest Correct Assertion
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
- **Never** use `waitForTimeout()` to hide a race.
```typescript
// ✅ Single value — use expect.poll
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
.toBe(3)
// ✅ Locator count — use toHaveCount
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
// ✅ Multiple conditions — use toPass
await expect(async () => {
expect(await node1.getValue()).toBe('foo')
expect(await node2.getValue()).toBe('bar')
}).toPass({ timeout: 5000 })
```
#### Rule: Wait for the Real Readiness Boundary
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
Common readiness boundaries:
| After this action... | Wait for... |
| -------------------------------------- | ------------------------------------------------------------ |
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
| Menu item click | `await contextMenu.waitForHidden()` |
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
| Settings write | Poll the setting value with `expect.poll()` |
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
| Graph mutation (add/remove node, link) | Poll link/node count |
| Clipboard write | Poll pasted value |
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
#### Rule: Expose Locators for Retrying Assertions
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
```typescript
// Helper exposes locator
get domWidgets(): Locator {
return this.page.locator('.dom-widget')
}
// Caller uses retrying assertion
await expect(comfyPage.domWidgets).toHaveCount(2)
```
Replace count methods with locator getters so callers can use retrying assertions directly.
#### Rule: Fix Check-then-Act Races in Helpers
```typescript
// ❌ Race: count can change between check and waitFor
const count = await locator.count()
if (count > 0) {
await locator.waitFor({ state: 'hidden' })
}
// ✅ Direct: waitFor handles both cases
await locator.waitFor({ state: 'hidden' })
```
#### Rule: Remove force:true from Clicks
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
```typescript
// ❌ Hides the race
await closeButton.click({ force: true })
// ✅ Surfaces the real issue — fix with proper wait
await closeButton.click()
await dialog.waitForHidden()
```
#### Rule: Handle Non-deterministic Element Order
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
```typescript
// ❌ Assumes order
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
// ✅ Find by ID or proximity
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
let target = nodes[0]
for (const n of nodes) {
const pos = await n.getPosition()
if (Math.abs(pos.y - expectedY) < minDist) target = n
}
```
Or guard the assumption:
```typescript
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes).toHaveLength(1)
const node = nodes[0]
```
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
```typescript
// ✅ Retry click+assert together
await expect(async () => {
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
await expect(dialog).toBeHidden({ timeout: 500 })
}).toPass({ timeout: 5000 })
```
### 4. Keep Changes Narrow
- Shared helpers should drive setup to a stable boundary.
- Do not encode one-spec timing assumptions into generic helpers.
- If a race only matters to one spec, prefer a local wait in that spec.
- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness.
### 5. Verify Narrowly
```bash
# Targeted rerun with repetition
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
# Single test by line number (avoids grep quoting issues on Windows)
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
```
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
- Verify with the smallest command that exercises the flaky path.
### 6. Watch CI E2E Runs
After pushing, use `gh` to monitor the E2E workflow:
```bash
# Find the run for the current branch
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
# Watch it live (blocks until complete, streams logs)
gh run watch <run-id>
# One-liner: find and watch the latest E2E run for the current branch
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
```
On Windows (PowerShell):
```powershell
# One-liner equivalent
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
```
After the run completes:
```bash
# Download the Playwright report artifact
gh run download <run-id> -n playwright-report
# View the run summary in browser
gh run view <run-id> --web
```
Also watch the unit test workflow in parallel if you changed helpers:
```bash
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
```
### 7. Pre-merge Checklist
Before merging a flaky-test fix, confirm:
- [ ] The latest CI artifact was inspected directly
- [ ] The root cause is stated as a race or readiness mismatch
- [ ] The fix waits on the real readiness boundary
- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass)
- [ ] The fix stays local unless a shared helper truly owns the race
- [ ] Local verification uses a targeted rerun
- [ ] No behavioral changes to the test — only timing/retry strategy updated
## Local Noise — Do Not Fix
These are local distractions, not CI root causes:
- Missing local input fixture files required by the test path
- Missing local models directory
- Teardown `EPERM` while restoring the local browser-test user data directory
- Local screenshot baseline differences on Windows
Rules:
- First confirm whether it blocks the exact flaky path under investigation.
- Do not commit temporary local assets used only for verification.
- Do not commit local screenshot baselines.

View File

@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |

View File

@@ -1,156 +0,0 @@
---
name: reviewing-unit-tests
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
---
# Reviewing Unit Tests for ComfyUI_frontend
## Overview
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
## Review Workflow
1. Identify the test type: component, store, composable, util, or bugfix regression.
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
3. Open the authoritative doc section before judging structure.
4. Scan the red flags below.
5. State the verdict first. Name the failure mode. Cite the doc or rule.
## Source of Truth / Precedence
When docs and examples conflict, use this order:
1. Explicit repo rules, lint rules, and note blocks.
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
4. Example snippets
5. Prior diffs
Apply these repo-specific clarifications:
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
- Motion != fix.
## 30-Second Red Flags
| If you see... | Failure mode | Default action |
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
## Rationalization Table
| Excuse | Reality |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
| "It is only a nit" | Explicit repo-rule violations are never nits. |
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
## Mocking Rules
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
### Alias-by-Renaming
```ts
// Before
const mockAdd = vi.hoisted(() => vi.fn())
// After: same indirection, new name
function getToastAddMock() {
return useToast().add
}
```
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
### `vi.mocked()` Scope
| Use case | `vi.mocked()` required? |
| --------------------------------------------------------------- | ----------------------- |
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
| `.mock.calls`, `.mock.results` | Yes |
| `expect(fn).toHaveBeenCalled()` | No |
| `expect(fn).toHaveBeenCalledWith(...)` | No |
- Flag casts whenever `vi.mocked()` would narrow correctly.
- Do not add `vi.mocked()` around assertion-only references just for style.
### Reset Hygiene
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
### Third-Party Seams
- Distinguish trivial hooks from behavior-rich APIs.
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
- That exception does not justify mocking behavior-rich third-party modules.
### `vue-i18n`
- Never mock `vue-i18n` in component tests.
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
## Test-Body Rules
| Smell | Review bar |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Change-detector test | Reject. Default values alone prove nothing. |
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
## Bugfix Regression Proof
For `fix:` PRs or bugfix diffs:
1. Identify the production change that fixes the bug.
2. Verify the new test fails on pre-fix code, or ask the author to show it.
3. If the test passes on broken code, request changes.
A regression test that never proves red does not pin the bug.
## Review Output Rules
- State verdict before procedural questions.
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
- Link the authoritative doc section in the review comment.
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
## Quick Reference
| When you see... | Read this |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
## Key Files to Read
| Purpose | Path |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |

View File

@@ -0,0 +1,361 @@
---
name: ticket-intake
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
---
# Ticket Intake
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
## Supported Sources
| Source | URL Pattern | Provider File |
| ------ | --------------------------------------------------- | --------------------- |
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
## Quick Start
When given a ticket URL:
1. **Detect source type** from URL pattern
2. **Load provider-specific logic** from `providers/` directory
3. Fetch ticket content via appropriate API
4. Extract and normalize properties to common schema
5. **Register ticket in pipeline API** ← MANDATORY
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
7. **Run verification script** to confirm API registration
8. Output summary and handoff to `research-orchestrator`
## Configuration
Uses the **production API** by default. No configuration needed for read operations.
**Defaults (no setup required):**
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
- Read-only endpoints at `/public/*` require no authentication
**For write operations** (transitions, creating tickets), set:
```bash
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
```
**Optional (for local working artifacts):**
```bash
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
```
## Mandatory API Calls (Execute ALL Three)
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
### Call 1: Create Ticket
```bash
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
API_KEY="${PIPELINE_API_KEY}"
curl -s -X POST "${API_URL}/v1/tickets" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"notion_page_id": "NOTION_PAGE_UUID_HERE",
"title": "TICKET_TITLE_HERE",
"source": "notion",
"metadata": {
"description": "DESCRIPTION_HERE",
"priority": "High",
"labels": [],
"acceptanceCriteria": []
}
}'
```
Save the returned `id` — you need it for the next two calls.
### Call 2: Transition to RESEARCH
```bash
TICKET_ID="id-from-step-1"
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"to_state": "RESEARCH",
"reason": "Intake complete, starting research"
}'
```
### Call 3: Queue Source Update
```bash
curl -s -X POST "${API_URL}/v1/sync/queue" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"ticket_id": "TICKET_ID_HERE",
"action": "update_status",
"payload": { "status": "In Progress" },
"priority": "normal"
}'
```
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
### TypeScript Equivalent (if using pipeline client)
```typescript
import { PipelineClient } from '@pipeline/client'
const client = new PipelineClient({
apiUrl:
process.env.PIPELINE_API_URL ||
'https://api-gateway-856475788601.us-central1.run.app',
agentId: process.env.AGENT_ID!
})
const ticket = await client.createTicket({
notion_page_id: pageId,
title: ticketTitle,
source: 'notion',
metadata: { description, priority, labels, acceptanceCriteria }
})
await client.transitionState(
ticket.id,
'RESEARCH',
'Intake complete, starting research'
)
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
```
## Workflow
### Step 1: Detect Source Type
Parse the URL to determine source:
```javascript
if (url.includes('notion.so')) {
source = 'notion'
// Load providers/notion.md
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
source = 'github'
// Load providers/github.md
} else {
// Error: Unsupported URL format
}
```
### Step 2: Load Provider and Fetch Data
Read the appropriate provider file for source-specific instructions:
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
Follow the provider's instructions for:
- Fetching content
- Extracting properties
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
### Step 3: Normalize to Common Schema
All providers must extract normalized ticket data following `schema.md`:
```json
{
"id": "abc12345",
"url": "https://...",
"source": "notion | github",
"title": "Ticket title",
"description": "Full description",
"status": "Not Started",
"assignee": "username",
"priority": "High",
"area": "UI",
"labels": ["bug", "frontend"],
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
"fetchedAt": "2024-01-15T10:30:00Z"
}
```
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
1. `createTicket()` → save the returned ticket ID
2. `transitionState(id, 'RESEARCH')` → confirm state changed
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
### Step 5: Run Verification Script
After making the API calls, run the verification script to confirm everything worked:
```bash
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
```
**If the script is not available locally**, verify manually via the public API:
```bash
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
```
Expected output:
```json
{
"id": "...",
"state": "RESEARCH",
"title": "...",
"notion_page_id": "..."
}
```
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
### Step 6: Output Summary and Handoff
Print a clear summary:
```markdown
## Ticket Intake Complete
**Source:** Notion | GitHub
**Title:** [Ticket title]
**ID:** abc12345
**Status:** In Progress (queued)
**Priority:** High
**Area:** UI
### Description
[Brief description or first 200 chars]
### Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
### Links
- **Ticket:** [Original URL]
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
### Pipeline
- **API Ticket ID:** abc12345
- **State:** RESEARCH
- **Verified:** ✅ (via verify-intake.sh or public API)
```
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
## Error Handling
### Unsupported URL
```
❌ Unsupported ticket URL format.
Supported formats:
- Notion: https://notion.so/... or https://www.notion.so/...
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
Received: [provided URL]
```
### Provider-Specific Errors
See individual provider files for source-specific error handling:
- `providers/notion.md` - Authentication, page not found
- `providers/github.md` - Auth, rate limits, issue not found
### Missing Properties
Continue with available data and note what's missing:
```
⚠️ Some properties unavailable:
- Priority: not found (using default: Medium)
- Area: not found
Proceeding with available data...
```
### API Call Failures
```
❌ Pipeline API call failed: {method} {endpoint}
Status: {status}
Error: {message}
Retrying once...
❌ Retry also failed. INTAKE IS INCOMPLETE.
The ticket was NOT registered in the pipeline.
Downstream skills will not work until this is fixed.
```
## Notes
- This skill focuses ONLY on intake — it does not do research
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
- Pipeline state is tracked via the API, not local files
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
- The `source` field in the ticket determines which research strategies to use
## API Client Reference
### Available Methods
| Method | Description |
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
| `getTicket(id)` | Retrieve a ticket by ID |
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
### Error Handling
```typescript
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
try {
await client.createTicket({ ... });
} catch (error) {
if (error instanceof PipelineAPIError) {
console.error(`API Error ${error.status}: ${error.message}`);
}
throw error;
}
```

View File

@@ -0,0 +1,194 @@
# GitHub Provider - Ticket Intake
Provider-specific logic for ingesting tickets from GitHub Issues.
## URL Pattern
```
https://github.com/{owner}/{repo}/issues/{number}
https://www.github.com/{owner}/{repo}/issues/{number}
```
Extract: `owner`, `repo`, `issue_number` from URL.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Access to the repository
## Fetch Issue Content
Use `gh` CLI to fetch issue details:
```bash
# Get issue details in JSON
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
# Get comments separately if needed
gh issue view {number} --repo {owner}/{repo} --comments
```
## Extract Ticket Data
Map GitHub issue fields to normalized ticket data (stored via API):
| GitHub Field | ticket.json Field | Notes |
| ------------ | ----------------- | -------------------------- |
| title | title | Direct mapping |
| body | description | Issue body/description |
| state | status | Map: open → "Not Started" |
| labels | labels | Array of label names |
| assignees | assignee | First assignee login |
| author | author | Issue author login |
| milestone | milestone | Milestone title if present |
| comments | comments | Array of comment objects |
| linkedPRs | linkedPRs | PRs linked to this issue |
### Priority Mapping
Infer priority from labels:
- `priority:critical`, `P0` → "Critical"
- `priority:high`, `P1` → "High"
- `priority:medium`, `P2` → "Medium"
- `priority:low`, `P3` → "Low"
- No priority label → "Medium" (default)
### Area Mapping
Infer area from labels:
- `area:ui`, `frontend`, `component:*` → "UI"
- `area:api`, `backend` → "API"
- `area:docs`, `documentation` → "Docs"
- `bug`, `fix` → "Bug"
- `enhancement`, `feature` → "Feature"
## Update Source
**For GitHub issues, update is optional but recommended.**
Add a comment to indicate work has started:
```bash
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
```
Optionally assign to self:
```bash
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
```
Log any updates via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
githubWrites: [
...(ticket.metadata?.githubWrites || []),
{
action: 'comment',
issueNumber: 123,
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
## GitHub-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "github",
"githubOwner": "Comfy-Org",
"githubRepo": "ComfyUI_frontend",
"githubIssueNumber": 123,
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
"labels": ["bug", "area:ui", "priority:high"],
"linkedPRs": [456, 789],
"dosuComment": "..." // Extracted Dosu bot analysis if present
}
```
## Dosu Bot Detection
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
```bash
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
```
Look for comments from:
- `dosu[bot]`
- `dosu-bot`
Extract Dosu analysis which typically includes:
- Root cause analysis
- Suggested files to modify
- Related issues/PRs
- Potential solutions
Store in ticket data via API:
```json
{
"dosuComment": {
"found": true,
"analysis": "...",
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
"relatedIssues": [100, 101]
}
}
```
## Extract Linked Issues/PRs
Parse issue body and comments for references:
- `#123` → Issue or PR reference
- `fixes #123`, `closes #123` → Linked issue
- `https://github.com/.../issues/123` → Full URL reference
Store in ticket data via API for research phase:
```json
{
"referencedIssues": [100, 101, 102],
"referencedPRs": [200, 201]
}
```
## Error Handling
### Authentication Error
```
⚠️ GitHub CLI not authenticated.
Run: gh auth login
```
### Issue Not Found
```
❌ GitHub issue not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this repository
- Run: gh auth status
```
### Rate Limiting
```
⚠️ GitHub API rate limited.
Wait a few minutes and try again.
Check status: gh api rate_limit
```

View File

@@ -0,0 +1,202 @@
# Notion Provider - Ticket Intake
Provider-specific logic for ingesting tickets from Notion.
## URL Pattern
```
https://www.notion.so/workspace/Page-Title-abc123def456...
https://notion.so/Page-Title-abc123def456...
https://www.notion.so/abc123def456...
```
Page ID is the 32-character hex string (with or without hyphens).
## Prerequisites
- Notion MCP connected and authenticated
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
- Authenticate via `/mcp` command if prompted
## Fetch Ticket Content
Use `Notion:notion-fetch` with the page URL or ID:
```
Fetch the full page content including all properties
```
## Extract Ticket Data
Extract these properties (names may vary):
| Property | Expected Name | Type |
| ------------- | ------------------------- | ------------ |
| Title | Name / Title | Title |
| Status | Status | Select |
| Assignee | Assignee / Assigned To | Person |
| Description | - | Page content |
| Slack Link | Slack Link / Slack Thread | URL |
| GitHub PR | GitHub PR / PR Link | URL |
| Priority | Priority | Select |
| Area | Area / Category | Select |
| Related Tasks | Related Tasks | Relation |
**If properties are missing**: Note what's unavailable and continue with available data.
## Update Source (REQUIRED)
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
Use `Notion:notion-update-page` to update the ticket:
1. **Status**: Set to "In Progress" (only valid from "Not Started")
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
```json
{
"page_id": "{page_id_from_ticket}",
"command": "update_properties",
"properties": {
"Status": "In Progress",
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
}
}
```
**After the update succeeds**, log the write via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
notionWrites: [
...(ticket.metadata?.notionWrites || []),
{
field: 'Status',
value: 'In Progress',
previousValue: 'Not Started',
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
If update fails, log with `success: false` and continue.
## Notion-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "notion",
"notionPageId": "abc123def456...",
"slackLink": "https://slack.com/...",
"relatedTasks": ["page-id-1", "page-id-2"]
}
```
## Slack Thread Handling
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
### Slack URL Conversion
Notion stores Slack links in `slackMessage://` format:
```
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
```
Convert to browser-clickable format:
```
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
```
**Example:**
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
(Remove the dot from the last timestamp and prefix with `p`)
### Fetching Thread Content
Load the `slackdump` skill and use the **export-thread** workflow:
```bash
# Export thread by URL
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
# Or by colon notation (channel_id:thread_ts)
slackdump dump CHANNEL_ID:THREAD_TS
```
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
## Database Reference: Comfy Tasks
The "Comfy Tasks" database has these properties (verify via `notion-search`):
- **Status values**: Not Started, In Progress, In Review, Done
- **Team assignment**: "Frontend Team" for unassigned tickets
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
### Pipeline Owner Details
When assigning tickets, use these identifiers:
| Platform | Identifier |
| --------------- | -------------------------------------- |
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
| Notion Name | Christian Byrne |
| Notion Email | cbyrne@comfy.org |
| Slack User ID | U087MJCDHHC |
| GitHub Username | christian-byrne |
**To update Assignee**, use the Notion User ID (not name):
```
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
```
### Finding Active Tickets
To list your active tickets:
```
Use Notion:notion-search for "Comfy Tasks"
Filter by Assignee = current user OR Team = "Frontend Team"
```
## Error Handling
### Authentication Error
```
⚠️ Notion authentication required.
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
Then authenticate via /mcp command.
```
### Page Not Found
```
❌ Notion page not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this page
- Try re-authenticating via /mcp
```

View File

@@ -0,0 +1,81 @@
# Ticket Schema
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
## Ticket Data Schema
```json
{
// Required fields (all sources)
"id": "string", // Unique identifier (short form)
"url": "string", // Original URL
"source": "notion | github", // Source type
"title": "string", // Ticket title
"description": "string", // Full description/body
"fetchedAt": "ISO8601", // When ticket was fetched
// Common optional fields
"status": "string", // Current status
"assignee": "string", // Assigned user
"priority": "string", // Priority level
"area": "string", // Category/area
"labels": ["string"], // Tags/labels
"acceptanceCriteria": ["string"] // List of AC items
// Source-specific fields (see providers)
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
}
```
## Ticket State Schema (via API)
State is managed via the Pipeline API using `client.transitionState()`:
```json
{
"ticketId": "string",
"state": "intake | research | planning | implementation | pr_created | done | failed",
"stateChangedAt": "ISO8601",
// Timestamps tracked by API
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
```
## Priority Normalization
All sources should normalize to these values:
| Normalized | Description |
| ---------- | ------------------------- |
| Critical | Production down, security |
| High | Blocking work, urgent |
| Medium | Normal priority (default) |
| Low | Nice to have, backlog |
## Status Normalization
Pipeline tracks these statuses internally:
| Status | Description |
| -------------- | ---------------------------- |
| research | Gathering context |
| planning | Creating implementation plan |
| implementation | Writing code |
| review | Code review in progress |
| qa | Quality assurance |
| done | PR merged or completed |
## ID Generation
IDs are generated by the API when creating tickets. For reference:
- **Notion**: First 8 characters of page ID
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
Examples:
- Notion: `abc12345`
- GitHub: `gh-comfy-org-frontend-123`

View File

@@ -114,7 +114,7 @@ await expect(async () => {
## CI Debugging
1. Download artifacts from failed CI run
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
2. Extract and view trace: `npx playwright show-trace trace.zip`
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
4. Reproduce CI: `CI=true pnpm test:browser`
5. Local runs: `pnpm test:browser:local`

View File

@@ -19,26 +19,15 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass 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.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
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: |

View File

@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -46,9 +46,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
# Ashby (apps/website careers page build).
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
# WEBSITE_ASHBY_API_KEY=
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org

View File

@@ -1,23 +0,0 @@
name: Ashby Pull
description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API'
inputs:
api_key:
description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).'
required: true
job_board_name:
description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh Ashby snapshot
shell: bash
env:
WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }}
run: pnpm --filter @comfyorg/website ashby:refresh-snapshot

View File

@@ -1,87 +0,0 @@
# Outputs default to 'true' for non-pull_request events (push, merge_group):
# granular path filtering is a PR-only optimization. This avoids the silent
# skip footgun where a job gated on e.g. `app-website-changes == 'true'`
# would never run on push.
#
# Shared dependency files (root package.json, pnpm-lock.yaml,
# pnpm-workspace.yaml) are folded into every app-* and packages-changes
# output so a lockfile bump correctly invalidates each granular gate. They
# are NOT folded into docs-changes.
#
# Two paths-filter steps are needed because predicate-quantifier=every is
# required for the negated globs in `should-run` but breaks multi-pattern
# OR filters like `docs:` and `deps:`.
#
# Requires the caller to have checked out the repository.
name: 'Detect Path Changes'
description: >
Computes typed *-changes outputs and a back-compat should-run for
path-gated CI jobs.
outputs:
should-run:
description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.'
value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }}
app-website-changes:
description: 'Shared deps or `apps/website/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }}
app-desktop-changes:
description: 'Shared deps or `apps/desktop-ui/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }}
app-frontend-changes:
description: 'Shared deps or `src/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }}
packages-changes:
description: 'Shared deps or `packages/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }}
storybook-changes:
description: 'Shared deps or `.storybook/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }}
docs-changes:
description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }}
dependency-changes:
description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }}
runs:
using: composite
steps:
- name: Filter typed changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
app_website:
- 'apps/website/**'
app_desktop:
- 'apps/desktop-ui/**'
app_frontend:
- 'src/**'
packages:
- 'packages/**'
storybook:
- '.storybook/**'
docs:
- 'docs/**'
- '**/*.md'
deps:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Filter relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: relevant
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
relevant:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'

View File

@@ -1,19 +0,0 @@
name: Cloud Nodes Pull
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
inputs:
api_key:
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh cloud nodes snapshot
shell: bash
env:
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot

View File

@@ -1,65 +0,0 @@
name: Find Workflow Run
description: Finds a workflow run for a given commit SHA and outputs its status and run ID.
inputs:
workflow-id:
description: The workflow filename (e.g., 'ci-size-data.yaml')
required: true
head-sha:
description: The commit SHA to find runs for
required: true
not-found-status:
description: Status to output when no run exists
required: false
default: pending
token:
description: GitHub token for API access
required: true
outputs:
status:
description: One of 'ready', 'pending', 'failed', or the not-found-status value
value: ${{ steps.find.outputs.status }}
run-id:
description: The workflow run ID (only set when status is 'ready')
value: ${{ steps.find.outputs.run-id }}
runs:
using: composite
steps:
- name: Find workflow run
id: find
uses: actions/github-script@v8
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
with:
github-token: ${{ inputs.token }}
script: |
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', process.env.NOT_FOUND_STATUS);
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));

View File

@@ -1,31 +0,0 @@
name: 'Lint and format verify'
description: >
Runs the lint/format/knip verification suite plus a conditional
browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and
ci-lint-format-queue.yaml (merge queue) so both paths run the exact
same checks. The caller is responsible for checkout and frontend setup
before invoking this action.
runs:
using: composite
steps:
- name: Detect browser_tests changes
id: changed-paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
browser_tests:
- 'browser_tests/**'
- name: Verify lint and format
shell: bash
run: |
pnpm lint
pnpm stylelint
pnpm format:check
pnpm knip
- name: Typecheck browser tests
if: steps.changed-paths.outputs.browser_tests == 'true'
shell: bash
run: pnpm typecheck:browser

View File

@@ -1,88 +0,0 @@
name: Resolve PR from workflow_run
description: >
Resolves the PR number from a workflow_run event using pull_requests[0]
with a listPullRequestsAssociatedWithCommit fallback.
Skips closed/merged PRs and stale runs (head SHA mismatch).
inputs:
token:
description: GitHub token for API calls
required: false
default: ${{ github.token }}
outputs:
skip:
description: "'true' when no open PR was found or the run is stale"
value: ${{ steps.resolve.outputs.skip }}
number:
description: The PR number (empty when skip is true)
value: ${{ steps.resolve.outputs.number }}
base:
description: The PR base branch (empty when skip is true)
value: ${{ steps.resolve.outputs.base }}
head-sha:
description: The PR head SHA (empty when skip is true)
value: ${{ steps.resolve.outputs.head-sha }}
runs:
using: composite
steps:
- name: Resolve PR
id: resolve
uses: actions/github-script@v8
with:
github-token: ${{ inputs.token }}
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
// Fork PRs: pull_requests is empty and commit SHA may not be in
// the base repo graph. Fall back to pulls.list with head filter.
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
per_page: 1,
});
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.state !== 'open') {
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
core.setOutput('skip', 'true');
return;
}
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(
`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`
);
core.setOutput('skip', 'true');
return;
}
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));

View File

@@ -1,79 +0,0 @@
name: Upsert Comment Section
description: >
Manage a consolidated PR comment with independently-updatable sections.
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
Valid section names: "e2e", "preview", "screenshot-update".
inputs:
pr-number:
description: PR number to comment on
required: true
section-name:
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
required: true
section-content:
description: Markdown content for this section
required: true
comment-marker:
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
required: true
token:
description: GitHub token with pull-requests write permission
required: true
runs:
using: composite
steps:
- uses: actions/github-script@v8
env:
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
INPUT_SECTION_NAME: ${{ inputs.section-name }}
INPUT_SECTION_CONTENT: ${{ inputs.section-content }}
INPUT_COMMENT_MARKER: ${{ inputs.comment-marker }}
with:
github-token: ${{ inputs.token }}
script: |
const prNumber = Number(process.env.INPUT_PR_NUMBER)
const sectionName = process.env.INPUT_SECTION_NAME
const sectionContent = process.env.INPUT_SECTION_CONTENT
const commentMarker = process.env.INPUT_COMMENT_MARKER
const sectionStart = `<!-- section:${sectionName}:start -->`
const sectionEnd = `<!-- section:${sectionName}:end -->`
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`
// Escape special regex characters in delimiter strings
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: prNumber }
)
const existing = comments.find(
(c) =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes(commentMarker)
)
if (!existing) {
return github.rest.issues.createComment({
...context.repo,
issue_number: prNumber,
body: `${commentMarker}\n${sectionBlock}`
})
}
const body = existing.body ?? ''
const sectionRegex = new RegExp(
`${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
)
const updated = sectionRegex.test(body)
? body.replace(sectionRegex, sectionBlock)
: body.trimEnd() + '\n\n' + sectionBlock
return github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body: updated
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,107 @@
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Registry API Types'
on:
# Manual trigger
workflow_dispatch:
# Triggered from comfy-api repo
repository_dispatch:
types: [comfy-api-updated]
jobs:
update-registry-types:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Checkout comfy-api repository
uses: actions/checkout@v6
with:
repository: Comfy-Org/comfy-api
path: comfy-api
token: ${{ secrets.COMFY_API_PAT }}
clean: true
- name: Get API commit information
id: api-info
run: |
cd comfy-api
API_COMMIT=$(git rev-parse --short HEAD)
echo "commit=${API_COMMIT}" >> $GITHUB_OUTPUT
cd ..
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
mkdir -p ./packages/registry-types/src
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
echo "No changes to Comfy Registry API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
else
echo "Changes detected in Comfy Registry API types."
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
title: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
body: |
## Automated API Type Update
This PR updates the Comfy Registry API types from the latest comfy-api OpenAPI specification.
- API commit: ${{ steps.api-info.outputs.commit }}
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
branch: update-registry-types-${{ steps.api-info.outputs.commit }}
base: main
labels: CNR
delete-branch: true
add-paths: |
packages/registry-types/src/comfyRegistryTypes.ts

View File

@@ -12,30 +12,17 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
scan:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -1,29 +0,0 @@
# Description: Lint and format verification for GitHub merge queue runs.
# Paired with ci-lint-format.yaml — workflow name and job name must match
# so branch protection resolves a single required check in both the
# pull_request and merge_group contexts. This file runs verify-only steps
# with a read-only token; auto-fix and PR comments live in the PR workflow.
name: 'CI: Lint Format'
on:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- name: Checkout merge group ref
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Verify lint and format
uses: ./.github/actions/lint-format-verify

View File

@@ -1,7 +1,4 @@
# Description: Linting and code formatting validation for pull requests.
# Paired with ci-lint-format-queue.yaml - workflow name and job name must
# match so branch protection resolves a single required check in both the
# pull_request and merge_group contexts.
# Description: Linting and code formatting validation for pull requests
name: 'CI: Lint Format'
on:
@@ -29,6 +26,14 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Detect browser_tests changes
id: changed-paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
browser_tests:
- 'browser_tests/**'
- name: Run ESLint with auto-fix
run: pnpm lint:fix
@@ -72,8 +77,16 @@ jobs:
echo "See CONTRIBUTING.md for more details."
exit 1
- name: Verify lint and format
uses: ./.github/actions/lint-format-verify
- name: Final validation
run: |
pnpm lint
pnpm stylelint
pnpm format:check
pnpm knip
- name: Typecheck browser tests
if: steps.changed-paths.outputs.browser_tests == 'true'
run: pnpm typecheck:browser
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository

View File

@@ -14,29 +14,16 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
validate-fonts:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -81,17 +68,15 @@ jobs:
echo '✅ No proprietary fonts found in dist'
validate-licenses:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -110,7 +95,7 @@ jobs:
if npx license-checker-rseidelsohn@4 \
--production \
--summary \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--clarificationsFile .github/license-clarifications.json \
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
echo ''

View File

@@ -3,8 +3,10 @@ name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
@@ -14,24 +16,12 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
perf-tests:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -54,14 +44,10 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
- name: Upload perf metrics
if: always()
@@ -72,6 +58,21 @@ jobs:
retention-days: 30
if-no-files-found: warn
- name: Save PR metadata
if: github.event_name == 'pull_request'
run: |
mkdir -p temp/perf-meta
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
- name: Upload PR metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v6
with:
name: perf-meta
path: temp/perf-meta/
- name: Save perf baseline to perf-data branch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
continue-on-error: true

View File

@@ -16,21 +16,8 @@ permissions:
contents: read
jobs:
changes:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
collect:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
@@ -45,6 +32,13 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6
with:

View File

@@ -1,151 +0,0 @@
name: 'CI: E2E Coverage'
on:
workflow_run:
workflows: ['CI: Tests E2E']
types:
- completed
concurrency:
group: e2e-coverage-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
merge:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download all shard coverage data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: e2e-coverage-shard-.*
name_is_regexp: true
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
path: coverage/playwright/
retention-days: 30
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,33 +30,49 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Test Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v7
with:
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-.*
name_is_regexp: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -73,6 +85,6 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -4,9 +4,10 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
merge_group:
paths-ignore: ['**/*.md']
workflow_dispatch:
concurrency:
@@ -14,20 +15,7 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
setup:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -65,7 +53,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -98,7 +86,6 @@ jobs:
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
- name: Upload blob report
uses: actions/upload-artifact@v6
@@ -108,22 +95,13 @@ jobs:
path: blob-report/
retention-days: 1
- name: Upload shard coverage data
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-shard-${{ matrix.shardIndex }}
path: coverage/playwright/
retention-days: 1
if-no-files-found: warn
playwright-tests:
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
timeout-minutes: 15
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -175,9 +153,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [changes, playwright-tests-chromium-sharded]
needs: [playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }}
if: ${{ !cancelled() }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -206,38 +184,14 @@ jobs:
path: ./playwright-report/
retention-days: 30
# Gate job — single required check that passes whether the matrix ran or was
# skipped. Branch rulesets require this instead of the individual matrix-
# expanded check names so PRs with no e2e-relevant changes aren't stuck.
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should-run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: >-
${{
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
@@ -256,15 +210,9 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, playwright-tests, merge-reports]
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: >-
${{
always() &&
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Storybook Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -69,6 +82,6 @@ jobs:
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -8,29 +8,10 @@ on:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
storybook-changes: ${{ steps.changes.outputs.storybook-changes }}
app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
@@ -49,13 +30,8 @@ jobs:
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request'
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request'
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -91,15 +67,8 @@ jobs:
# Chromatic deployment only for version-bump-* branches or manual triggers
chromatic-deployment:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true'))
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -138,15 +107,9 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, storybook-build]
needs: [storybook-build]
runs-on: ubuntu-latest
if: |
always()
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read

View File

@@ -1,32 +1,20 @@
# Description: Unit and component testing with Vitest + coverage reporting
# Description: Unit and component testing with Vitest
name: 'CI: Tests Unit'
on:
push:
branches: [main, master, dev*, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
merge_group:
paths-ignore: ['**/*.md']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
test:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -35,23 +23,5 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
with:
name: unit-coverage
path: coverage/lcov.info
retention-days: 30
if-no-files-found: warn
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Run Vitest tests
run: pnpm test:unit

View File

@@ -1,178 +0,0 @@
---
name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- 'core/**'
- 'cloud/**'
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
push:
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
env:
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build
- name: Fetch head commit metadata
id: head-commit
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
})
const author = data.author?.login || data.commit.author?.name || ''
const message = (data.commit.message || '').split('\n', 1)[0]
core.setOutput('author', author)
core.setOutput('message', message)
- name: Deploy project artifacts to Vercel
id: deploy
env:
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
GIT_PR_ID: ${{ github.event.pull_request.number }}
GIT_REPO: ${{ github.repository }}
run: |
URL=$(vercel deploy --prebuilt \
--meta githubCommitRef="$GIT_COMMIT_REF" \
--meta githubCommitSha="$GIT_COMMIT_SHA" \
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
--meta githubPrId="$GIT_PR_ID" \
--meta githubRepo="$GIT_REPO")
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Alias deployment to stable PR hostname
id: alias-set
continue-on-error: true
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
run: |
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
- name: Publish preview outputs
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
run: |
if [[ "$ALIAS_OK" == "true" ]]; then
STABLE_URL="https://$ALIAS_HOST"
else
STABLE_URL="$DEPLOY_URL"
fi
mkdir -p temp/vercel-preview
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
{
echo "**Preview:** $STABLE_URL"
if [[ "$ALIAS_OK" == "true" ]]; then
echo "**This commit:** $DEPLOY_URL"
else
echo "_Stable alias update failed — URL reflects this commit only._"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload preview metadata
uses: actions/upload-artifact@v6
with:
name: vercel-preview
path: temp/vercel-preview
deploy-production:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
env:
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: |
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
exit 1
fi
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod)
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,42 +0,0 @@
# Description: Build and validate the marketing website (apps/website)
name: 'CI: Website Build'
on:
push:
branches: [main, master, website/*]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
build:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build

View File

@@ -1,270 +0,0 @@
name: 'CI: Website E2E'
on:
push:
branches: [main]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
website-e2e:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: read
outputs:
test-outcome: ${{ steps.tests.outcome }}
report-url: ${{ steps.deploy.outputs.url }}
screenshot-failures: ${{ steps.failures.outputs.screenshot }}
other-failures: ${{ steps.failures.outputs.other }}
# Evaluated at job level (not from a step) — static expression.
is-pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests
id: tests
run: pnpm --filter @comfyorg/website test:e2e
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-playwright-report
path: apps/website/playwright-report/
retention-days: 30
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
BRANCH=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g;s/--*/-/g;s/^-\|-$//g')
DEPLOY_OK=false
for i in 1 2 3; do
echo "Deployment attempt $i of 3..."
OUTPUT=$(npx wrangler@^4.0.0 pages deploy apps/website/playwright-report \
--project-name=comfyui-website-e2e \
--branch="$BRANCH" 2>&1) && { DEPLOY_OK=true; break; } || echo "$OUTPUT"
[ $i -lt 3 ] && sleep 10
done
echo "$OUTPUT"
if [ "$DEPLOY_OK" != "true" ]; then
echo "::error::All 3 deployment attempts failed"
exit 1
fi
URL=$(echo "$OUTPUT" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
echo "url=${URL}" >> $GITHUB_OUTPUT
- name: Categorize failures
id: failures
if: always() && !cancelled() && steps.tests.outcome != 'success'
uses: actions/github-script@v8
with:
script: |
const fs = require('fs')
const report = JSON.parse(fs.readFileSync('apps/website/results.json', 'utf8'))
function isFailed(t) { return t.status === 'unexpected' || t.status === 'flaky' }
function isVisual(spec) {
return spec.file?.includes('visual') ||
spec.tests?.some(t => t.results?.some(r => r.error?.message?.includes('toHaveScreenshot')))
}
function specsOf(suite) {
return [
...(suite.specs || []),
...(suite.suites || []).flatMap(specsOf)
]
}
// True: Visual
// False: Other
const failed = specsOf(report)
.flatMap(spec => (spec.tests || [])
.filter(isFailed)
.map(() => isVisual(spec)))
const screenshotFailures = failed.filter(Boolean).length
core.setOutput('screenshot', screenshotFailures)
core.setOutput('other', failed.length - screenshotFailures)
- name: Write job summary
if: always() && !cancelled()
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ steps.tests.outcome }}
REPORT_URL: ${{ steps.deploy.outputs.url }}
SCREENSHOT_FAILURES: ${{ steps.failures.outputs.screenshot }}
OTHER_FAILURES: ${{ steps.failures.outputs.other }}
with:
script: |
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
await core.summary.addRaw(lines.join('\n')).write()
post-starting-comment:
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
needs: changes
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🌐 Website E2E
<!-- WEBSITE_E2E_STATUS -->
> [!NOTE]
> Tests are running… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: website-e2e
if: always() && !cancelled() && needs.website-e2e.outputs.is-pr == 'true'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build e2e section content
id: content
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ needs.website-e2e.outputs.test-outcome }}
REPORT_URL: ${{ needs.website-e2e.outputs.report-url }}
SCREENSHOT_FAILURES: ${{ needs.website-e2e.outputs.screenshot-failures }}
OTHER_FAILURES: ${{ needs.website-e2e.outputs.other-failures }}
with:
script: |
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '<!-- WEBSITE_E2E_STATUS -->', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
if (screenshotFailures > 0) {
const s = screenshotFailures === 1 ? '' : 's'
lines.push('', `- [ ] Update website screenshots (${screenshotFailures} screenshot diff${s})`)
}
if (otherFailures > 0) {
lines.push(
'',
'> [!WARNING]',
`> ${otherFailures} non-screenshot failure${otherFailures === 1 ? '' : 's'} — these require manual review.`
)
}
core.setOutput('section-content', lines.join('\n'))
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

@@ -1,149 +0,0 @@
name: 'Coverage: Slack Notification'
on:
workflow_run:
workflows: ['CI: Tests Unit']
branches: [main]
types:
- completed
permissions:
contents: read
actions: read
pull-requests: read
jobs:
notify:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download current unit coverage
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: unit-coverage
path: coverage
- name: Download previous unit coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: unit-coverage-baseline
path: temp/coverage-baseline
if_no_artifact_found: warn
- name: Download latest E2E coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: ci-tests-e2e-coverage.yaml
name: e2e-coverage
path: temp/e2e-coverage
if_no_artifact_found: warn
- name: Download previous E2E coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: e2e-coverage-baseline
path: temp/e2e-coverage-baseline
if_no_artifact_found: warn
- name: Resolve merged PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const sha = context.payload.workflow_run.head_sha;
const { data: commit } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: sha,
});
const message = commit.commit.message ?? '';
const firstLine = message.split('\n')[0];
const match = firstLine.match(/\(#(\d+)\)\s*$/);
if (!match) {
core.setOutput('skip', 'true');
core.info('No PR number found in commit message — skipping.');
return;
}
const prNumber = match[1];
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('skip', 'false');
core.setOutput('number', prNumber);
core.setOutput('url', pr.html_url);
core.setOutput('author', pr.user.login);
- name: Generate Slack notification
if: steps.pr-meta.outputs.skip != 'true'
id: slack-payload
env:
PR_URL: ${{ steps.pr-meta.outputs.url }}
PR_NUMBER: ${{ steps.pr-meta.outputs.number }}
PR_AUTHOR: ${{ steps.pr-meta.outputs.author }}
run: |
PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \
--pr-url="$PR_URL" \
--pr-number="$PR_NUMBER" \
--author="$PR_AUTHOR")
if [ -n "$PAYLOAD" ]; then
echo "has_payload=true" >> "$GITHUB_OUTPUT"
DELIM="SLACK_PAYLOAD_$(date +%s)"
echo "payload<<$DELIM" >> "$GITHUB_OUTPUT"
printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT"
echo "$DELIM" >> "$GITHUB_OUTPUT"
else
echo "has_payload=false" >> "$GITHUB_OUTPUT"
fi
- name: Post to Slack
if: steps.slack-payload.outputs.has_payload == 'true'
continue-on-error: true
env:
SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
run: |
# Channel: #p-deprecated-frontend-automated-testing
BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}')
curl -sf -X POST \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" \
-o /dev/null \
https://slack.com/api/chat.postMessage
- name: Save unit coverage baseline
if: always() && hashFiles('coverage/lcov.info') != ''
uses: actions/upload-artifact@v6
with:
name: unit-coverage-baseline
path: coverage/lcov.info
retention-days: 90
if-no-files-found: warn
- name: Save E2E coverage baseline
if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != ''
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-baseline
path: temp/e2e-coverage/coverage.lcov
retention-days: 90
if-no-files-found: warn

View File

@@ -1,123 +0,0 @@
name: Model Page Discovery
on:
schedule:
- cron: '0 9 * * 1'
workflow_dispatch:
jobs:
discover:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Fetch model labels from hub API
id: hub
shell: bash
run: |
set -euo pipefail
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v6
with:
sparse-checkout: apps/website/src/config/generated-models.json
- name: Compare against existing models
id: compare
shell: bash
run: |
set -euo pipefail
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
EXISTING_SLUGS=$(node -e "
const fs = require('fs');
const models = JSON.parse(
fs.readFileSync(
'apps/website/src/config/generated-models.json',
'utf8'
)
);
console.log(JSON.stringify(models.map(m => m.slug)));
" 2>/dev/null || echo '[]')
ADDED_SLUGS=$(node -e "
const hub = $HUB_SLUGS;
const existing = new Set($EXISTING_SLUGS);
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
")
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
if [ "\$COUNT" -eq 0 ]; then
echo "No new models found."
else
echo "Found \$COUNT new model(s)"
fi
- name: Check for existing open discovery issue
id: existing_issue
if: steps.compare.outputs.new_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
COUNT=$(gh issue list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--search 'in:title "New models detected"' \
--json number \
--jq 'length')
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
- name: Open GitHub issue for new models
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count == '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
shell: bash
run: |
SLUG_LIST=$(node -e "
const slugs = $NEW_SLUGS;
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
")
gh issue create \
--repo "$GITHUB_REPOSITORY" \
--title "New models detected — add to model pages" \
--body "## $NEW_COUNT new model(s) found in hub
The weekly model discovery scan found model labels on the hub not yet in
\`apps/website/src/config/generated-models.json\`.
### New slugs ($NEW_COUNT)
$SLUG_LIST
### Next steps
1. Review which of these warrant an SEO model page
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
---
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
- name: Skip — open issue already exists
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count != '0'
run: echo "An open discovery issue already exists — skipping creation."
- name: No new models found
if: steps.compare.outputs.new_count == '0'
run: echo "No new models found — nothing to do."

View File

@@ -2,7 +2,7 @@ name: 'PR: Unified Report'
on:
workflow_run:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
workflows: ['CI: Size Data', 'CI: Performance Report']
types:
- completed
@@ -30,25 +30,110 @@ jobs:
- name: Resolve PR from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
- name: Find size workflow run
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
// Verify the workflow_run head SHA matches the current PR head
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
- name: Find size workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-size
uses: ./.github/actions/find-workflow-run
uses: actions/github-script@v8
with:
workflow-id: ci-size-data.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-size-data.yaml',
head_sha: headSha,
per_page: 1,
});
- name: Find perf workflow run
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Find perf workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-perf
uses: ./.github/actions/find-workflow-run
uses: actions/github-script@v8
with:
workflow-id: ci-perf-report.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-perf-report.yaml',
head_sha: headSha,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Download size data (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
@@ -69,25 +154,6 @@ jobs:
path: temp/size-prev
if_no_artifact_found: warn
- name: Find coverage workflow run
if: steps.pr-meta.outputs.skip != 'true'
id: find-coverage
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-e2e-coverage.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
not-found-status: skip
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download coverage data
if: steps.pr-meta.outputs.skip != 'true' && steps.find-coverage.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: e2e-coverage
run_id: ${{ steps.find-coverage.outputs.run-id }}
path: temp/coverage
if_no_artifact_found: warn
- name: Download perf metrics (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
@@ -123,10 +189,9 @@ jobs:
- name: Generate unified report
if: steps.pr-meta.outputs.skip != 'true'
run: >
pnpm exec tsx scripts/unified-report.ts
node scripts/unified-report.js
--size-status=${{ steps.find-size.outputs.status }}
--perf-status=${{ steps.find-perf.outputs.status }}
--coverage-status=${{ steps.find-coverage.outputs.status }}
> pr-report.md
- name: Remove legacy separate comments

View File

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

View File

@@ -1,245 +0,0 @@
name: 'PR: Update Website Screenshots'
on:
pull_request:
types: [labeled]
issue_comment:
types: [created, edited]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: false
jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: write
issues: write
pull-requests: read
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
outputs:
pr-number: ${{ steps.pr-info.outputs.pr-number }}
update-outcome: ${{ steps.update-screenshots.outcome }}
has-changes: ${{ steps.commit.outputs.has-changes }}
changed-count: ${{ steps.commit.outputs.changed-count }}
steps:
- name: Verify sender permissions
if: >
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->')
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username: context.actor
})
if (!['admin', 'write'].includes(data.permission)) {
core.setFailed(`User ${context.actor} does not have write access`)
}
- name: Get PR info
id: pr-info
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ github.event.number || github.event.issue.number }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER)
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: prNumber
})
core.setOutput('pr-number', prNumber)
core.setOutput('branch', pr.head.ref)
- uses: actions/checkout@v6
with:
ref: ${{ steps.pr-info.outputs.branch }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
id: update-screenshots
run: pnpm --filter @comfyorg/website test:visual:update
continue-on-error: true
- name: Commit updated screenshots
id: commit
if: steps.update-screenshots.outcome == 'success'
run: |
git config --global --add safe.directory "$(pwd)"
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
CHANGED=$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/ | wc -l)
echo "changed-count=${CHANGED}" >> $GITHUB_OUTPUT
if [ "$CHANGED" -eq 0 ]; then
echo "No screenshot changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "has-changes=true" >> $GITHUB_OUTPUT
git add apps/website/e2e/
git commit -m "[automated] Update website screenshot expectations"
git push origin ${{ steps.pr-info.outputs.branch }}
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-screenshot-update-report
path: apps/website/playwright-report/
retention-days: 14
- name: Remove label
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}
with:
script: |
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: Number(process.env.PR_NUMBER),
name: 'Update Website Screenshots'
})
} catch (e) {
if (e.status !== 404) {
throw e
}
core.info('Label "Update Website Screenshots" was already removed')
}
post-starting-comment:
# Runs in parallel with update-screenshots to show "in progress" immediately.
# ⚠️ This condition is duplicated from `update-screenshots` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.number || github.event.issue.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.number || github.event.issue.number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 📸 Screenshot Update
> [!NOTE]
> Updating screenshots… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: update-screenshots
if: always() && !cancelled() && needs.update-screenshots.result != 'skipped'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ needs.update-screenshots.outputs.pr-number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build screenshot-update section content
id: content
uses: actions/github-script@v8
env:
UPDATE_OUTCOME: ${{ needs.update-screenshots.outputs.update-outcome }}
HAS_CHANGES: ${{ needs.update-screenshots.outputs.has-changes }}
CHANGED_COUNT: ${{ needs.update-screenshots.outputs.changed-count }}
with:
script: |
const outcome = process.env.UPDATE_OUTCOME
const hasChanges = process.env.HAS_CHANGES === 'true'
const changedCount = parseInt(process.env.CHANGED_COUNT) || 0
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const lines = ['## 📸 Screenshot Update', '']
if (outcome !== 'success') {
lines.push(
'> [!CAUTION]',
`> Screenshot update failed. [View workflow run](${runUrl})`
)
} else if (!hasChanges) {
lines.push(
'> [!TIP]',
'> All screenshots are already up to date.'
)
} else {
const s = changedCount === 1 ? '' : 's'
lines.push(
'> [!TIP]',
`> Updated ${changedCount} screenshot${s} and pushed to the branch.`
)
}
core.setOutput('section-content', lines.join('\n'))
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ needs.update-screenshots.outputs.pr-number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

@@ -1,70 +0,0 @@
---
name: 'PR: Vercel Website Preview'
on:
workflow_run:
workflows: ['CI: Vercel Website Preview']
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
actions: read
# Uses head_branch as proxy for PR number (unavailable at job-level in workflow_run).
# Preview and E2E comment writes are NOT mutually serialized — the race window is
# small and self-healing on next push.
concurrency:
group: website-pr-comment-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v6
- name: Download preview metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: vercel-preview
run_id: ${{ github.event.workflow_run.id }}
path: temp/vercel-preview
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Read preview URLs
if: steps.pr-meta.outputs.skip != 'true'
id: urls
run: |
echo "stable-url=$(cat temp/vercel-preview/stable-url.txt)" >> "$GITHUB_OUTPUT"
echo "unique-url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
echo "short-sha=${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT"
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
- name: Post preview comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
section-name: preview
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🔗 Website Preview
**Website Preview:** ${{ steps.urls.outputs.stable-url }}
<sub>This commit: ${{ steps.urls.outputs.unique-url }}</sub>
<sub>Last updated: ${{ github.event.workflow_run.updated_at }} for `${{ steps.urls.outputs.short-sha }}`</sub>

View File

@@ -1,23 +1,14 @@
# Release workflow for ComfyUI frontend: version bump → PyPI publish → ComfyUI PR.
# Runs on a bi-weekly schedule for minor releases, or manually for patch/hotfix releases.
name: 'Release: ComfyUI'
# Automated bi-weekly workflow to bump ComfyUI frontend RC releases
name: 'Release: Bi-weekly ComfyUI'
on:
# Bi-weekly schedule: Monday at 20:00 UTC
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
schedule:
- cron: '0 20 * * 1'
# Manual trigger for both on-demand minor and patch/hotfix releases
# Allow manual triggering (bypasses bi-weekly check)
workflow_dispatch:
inputs:
release_type:
description: 'minor = next minor version (bi-weekly cadence), patch = hotfix for current production version'
required: true
default: 'minor'
type: choice
options:
- minor
- patch
comfyui_fork:
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
required: false
@@ -50,11 +41,10 @@ jobs:
- name: Summary
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -86,8 +76,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
package_json_file: frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -101,8 +89,6 @@ jobs:
- name: Resolve release information
id: resolve
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
run: |
set -euo pipefail

View File

@@ -142,20 +142,6 @@ jobs:
fi
echo "✅ Branch '$BRANCH' exists"
- name: Ensure packageManager field exists
run: |
if ! grep -q '"packageManager"' package.json; then
# Old branches (e.g. core/1.42) predate the packageManager field.
# Inject it so pnpm/action-setup can resolve the version.
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
pkg.packageManager = 'pnpm@10.33.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Injected packageManager into package.json for legacy branch"
fi
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0

View File

@@ -1,70 +0,0 @@
# Description: Manual workflow to refresh the apps/website Ashby roles and
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
# Vercel website production deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
workflow_dispatch:
concurrency:
group: release-website
cancel-in-progress: true
jobs:
refresh-snapshots:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: main
persist-credentials: false
- name: Refresh Ashby snapshot
uses: ./.github/actions/ashby-pull
with:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh cloud nodes snapshot
uses: ./.github/actions/cloud-nodes-pull
with:
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
body: |
Automated refresh of remote-data snapshots used by the website
build:
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
`/api/object_info`
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-website-snapshots-${{ github.run_id }}
base: main
labels: |
Release:Website
delete-branch: true

1
.gitignore vendored
View File

@@ -66,6 +66,7 @@ dist.zip
/temp/
/tmp/
.superpowers/
# Generated JSON Schemas
/schemas/

View File

@@ -1,13 +1,5 @@
#!/usr/bin/env bash
# Skip in CI: the canonical knip check runs in ci-lint-format on every
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
# setup-comfyui-server, which contaminates knip's project glob with the
# devtools copy under custom_nodes and produces false-positive failures.
if [ -n "${CI-}" ]; then
exit 0
fi
# Run Knip with cache via package script
pnpm knip 1>&2

View File

@@ -64,7 +64,6 @@
]
}
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
@@ -84,16 +83,6 @@
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"it": "for",
"describe": "for",
"suite": "for"
}
],
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",
@@ -115,7 +104,8 @@
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error"
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
"overrides": [
{
@@ -126,60 +116,13 @@
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],
"rules": {
"typescript/no-explicit-any": "error",
"no-async-promise-executor": "error",
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error",
"playwright/consistent-spacing-between-blocks": "error",
"playwright/expect-expect": [
"error",
{
"assertFunctionNames": [
"recordMeasurement",
"logMeasurement",
"builderSaveAs"
],
"assertFunctionPatterns": [
"^expect",
"^assert",
"^verify",
"^searchAndExpect",
"waitForOpen",
"waitForClosed",
"waitForRequest"
]
}
],
"playwright/max-nested-describe": "error",
"playwright/no-duplicate-hooks": "error",
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",
"playwright/no-unsafe-references": "error",
"playwright/no-unused-locators": "error",
"playwright/no-useless-await": "error",
"playwright/no-useless-not": "error",
"playwright/no-wait-for-navigation": "error",
"playwright/no-wait-for-selector": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/prefer-hooks-on-top": "error",
"playwright/prefer-locator": "error",
"playwright/prefer-to-have-count": "error",
"playwright/prefer-to-have-length": "error",
"playwright/prefer-web-first-assertions": "error",
"playwright/prefer-native-locators": "error",
"playwright/require-to-pass-timeout": "error",
"playwright/valid-expect": "error",
"playwright/valid-expect-in-promise": "error",
"playwright/valid-title": "error"
"unicorn/no-empty-file": "error"
}
}
]

View File

@@ -67,7 +67,6 @@
"ignoreFiles": [
"node_modules/**",
"dist/**",
"**/dist/**",
"playwright-report/**",
"public/**",
"src/lib/litegraph/**"

View File

@@ -1,12 +1,16 @@
{
"recommendations": [
"antfu.vite",
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"oxc.oxc-vscode",

View File

@@ -44,7 +44,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally
@@ -180,12 +179,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
## Design Standards
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
## Testing Guidelines
See @docs/testing/\*.md for detailed patterns.
@@ -223,7 +216,6 @@ See @docs/testing/\*.md for detailed patterns.
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
## External Resources
@@ -233,7 +225,6 @@ See @docs/testing/\*.md for detailed patterns.
- shadcn/vue: <https://www.shadcn-vue.com/>
- Reka UI: <https://reka-ui.com/>
- PrimeVue: <https://primevue.org>
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
@@ -312,16 +303,13 @@ When referencing Comfy-Org repos:
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`
- NEVER use `:class="[]"` to merge class names
- Always use `import { cn } from '@comfyorg/tailwind-utils'`
- Always use `import { cn } from '@/utils/tailwindUtil'`
- e.g. `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules

View File

@@ -41,49 +41,12 @@
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
# Painter
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
# GLSL
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata

View File

@@ -62,37 +62,6 @@ python main.py --port 8188 --cpu
- Run `pnpm dev:electron` to start the dev server with electron API mocked
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
#### Testing with Cloud & Staging Environments
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
**Option 1: Frontend — `pnpm dev:cloud`**
The simplest approach. This proxies all API requests to the test cloud environment:
```bash
pnpm dev:cloud
```
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
```bash
# .env
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
```
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
**Option 2: Backend — `--comfy-api-base`**
Alternatively, launch the ComfyUI backend pointed at the staging API:
```bash
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
```
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.

View File

@@ -0,0 +1,16 @@
<!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

@@ -0,0 +1,61 @@
{
"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

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

@@ -0,0 +1,476 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,194 @@
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

@@ -0,0 +1,430 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,107 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,67 @@
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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,46 @@
@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

@@ -0,0 +1,208 @@
#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

@@ -0,0 +1,92 @@
#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

@@ -0,0 +1,38 @@
#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

@@ -0,0 +1,103 @@
#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

@@ -0,0 +1,118 @@
#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

@@ -0,0 +1,41 @@
.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

@@ -0,0 +1,77 @@
: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

@@ -0,0 +1,146 @@
// --- 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

@@ -0,0 +1,117 @@
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

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,194 @@
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

@@ -0,0 +1,92 @@
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

@@ -0,0 +1,83 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,21 @@
{
"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

@@ -0,0 +1,26 @@
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

@@ -5,14 +5,12 @@
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},
"dependencies": {
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@primevue/core": "catalog:",
"@primevue/themes": "catalog:",
"@vueuse/core": "catalog:",

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-700);
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-600);
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-500);
background-color: var(--color-coral-red-400);
}
.task-div .p-card {

View File

@@ -32,7 +32,7 @@ import { useI18n } from 'vue-i18n'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()

View File

@@ -1,97 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import StartupDisplay from '@/components/common/StartupDisplay.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
})
const ProgressBarStub = {
props: ['mode', 'value', 'showValue'],
template:
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
}
function renderDisplay(
props: {
progressPercentage?: number
title?: string
statusText?: string
hideProgress?: boolean
fullScreen?: boolean
} = {}
) {
return render(StartupDisplay, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: { ProgressBar: ProgressBarStub }
}
})
}
describe('StartupDisplay', () => {
describe('progressMode', () => {
it('renders indeterminate mode when progressPercentage is undefined', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'indeterminate'
)
})
it('renders determinate mode when progressPercentage is provided', () => {
renderDisplay({ progressPercentage: 50 })
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'determinate'
)
})
it('passes progressPercentage as value to the progress bar', () => {
renderDisplay({ progressPercentage: 75 })
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
})
})
describe('hideProgress', () => {
it('hides the progress bar when hideProgress is true', () => {
renderDisplay({ hideProgress: true })
expect(screen.queryByTestId('progress-bar')).toBeNull()
})
it('shows the progress bar by default', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar')).toBeDefined()
})
})
describe('title', () => {
it('renders the title text when provided', () => {
renderDisplay({ title: 'Loading...' })
expect(screen.getByText('Loading...')).toBeDefined()
})
it('does not render h1 when title is not provided', () => {
renderDisplay()
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
})
})
describe('statusText', () => {
it('renders statusText with data-testid attribute', () => {
renderDisplay({ statusText: 'Starting server' })
expect(screen.getByTestId('startup-status-text').textContent).toContain(
'Starting server'
)
})
it('does not render statusText element when not provided', () => {
renderDisplay()
expect(screen.queryByTestId('startup-status-text')).toBeNull()
})
})
})

View File

@@ -1,208 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
checkUrlReachable: vi.fn()
}))
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
import UrlInput from '@/components/common/UrlInput.vue'
import { ValidationState } from '@/utils/validationUtil'
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue', 'blur'],
template: `<input
data-testid="url-input"
:value="modelValue"
:data-invalid="invalid"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur')"
/>`
}
const InputIconStub = {
template: '<span data-testid="input-icon" />'
}
const IconFieldStub = {
template: '<div><slot /></div>'
}
function renderUrlInput(
modelValue = '',
validateUrlFn?: (url: string) => Promise<boolean>
) {
return render(UrlInput, {
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
}
describe('UrlInput', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('initial validation on mount', () => {
it('stays IDLE when modelValue is empty on mount', async () => {
renderUrlInput('')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(true)
renderUrlInput('https://example.com')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets INVALID state when URL is not reachable on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://unreachable.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
})
})
describe('input handling', () => {
it('resets validation state to IDLE on user input', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://bad.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
const user = userEvent.setup()
await user.type(screen.getByTestId('url-input'), 'x')
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
it('strips whitespace from typed input', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
const input = screen.getByTestId('url-input')
await user.type(input, 'htt ps')
expect((input as HTMLInputElement).value).not.toContain(' ')
})
})
describe('blur handling', () => {
it('emits update:modelValue on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
expect(onUpdate).toHaveBeenCalled()
})
it('normalizes URL on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
const emittedUrl = onUpdate.mock.calls[0]?.[0]
expect(emittedUrl).toBe('https://example.com/')
})
})
describe('custom validateUrlFn', () => {
it('uses custom validateUrlFn when provided', async () => {
const customValidator = vi.fn().mockResolvedValue(true)
renderUrlInput('https://custom.example', customValidator)
await waitFor(() => {
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
})
expect(checkUrlReachable).not.toHaveBeenCalled()
})
})
describe('state-change emission', () => {
it('emits state-change when validation state changes', async () => {
const onStateChange = vi.fn()
vi.mocked(checkUrlReachable).mockResolvedValue(true)
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onState-change': onStateChange
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
await waitFor(() => {
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
})
})
})
})

View File

@@ -1,112 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
getPlatform: vi.fn().mockReturnValue('win32')
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
te: () => false,
st: (_key: string, fallback: string) => fallback
}))
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import GpuPicker from '@/components/install/GpuPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} }
})
const HardwareOptionStub = {
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
emits: ['click'],
template:
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
}
function renderPicker(device: TorchDeviceType | null = null) {
return render(GpuPicker, {
props: { device },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
HardwareOption: HardwareOptionStub,
Tag: {
props: ['value'],
template: '<span data-testid="recommended-tag">{{ value }}</span>'
}
}
}
})
}
describe('GpuPicker', () => {
describe('recommended badge', () => {
it('shows recommended badge for nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('shows recommended badge for amd', () => {
renderPicker('amd')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('does not show recommended badge for cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge for unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge when no device is selected', () => {
renderPicker(null)
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
})
describe('selection state', () => {
it('marks nvidia as selected when device is nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
})
it('marks cpu as selected when device is cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
})
it('marks unsupported as selected when device is unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
})
it('no option is selected when device is null', () => {
renderPicker(null)
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
})
})
describe('gpu options on non-darwin platform', () => {
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
renderPicker(null)
expect(screen.getByTestId('NVIDIA')).toBeDefined()
expect(screen.getByTestId('AMD')).toBeDefined()
expect(screen.getByTestId('CPU')).toBeDefined()
expect(screen.getByTestId('Manual Install')).toBeDefined()
})
})
})

View File

@@ -4,7 +4,7 @@
<button
:class="
cn(
'hardware-option flex h-[190px] w-[170px] flex-col items-center rounded-3xl border-4 bg-neutral-900/70 p-5 transition-all duration-200',
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@@ -12,13 +12,13 @@
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container flex h-[110px] w-[110px] shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800"
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="size-full object-cover"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
@@ -28,7 +28,7 @@
</div>
<!-- Text Content -->
<div v-if="subtitle" class="mt-4 text-center">
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string

View File

@@ -1,223 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createI18n } from 'vue-i18n'
const mockValidateComfyUISource = vi.fn()
const mockShowDirectoryPicker = vi.fn()
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
validateComfyUISource: mockValidateComfyUISource,
showDirectoryPicker: mockShowDirectoryPicker
}))
}))
import MigrationPicker from '@/components/install/MigrationPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
install: {
migrationSourcePathDescription: 'Source path description',
migrationOptional: 'Migration is optional',
selectItemsToMigrate: 'Select items to migrate',
pathValidationFailed: 'Validation failed',
failedToSelectDirectory: 'Failed to select directory',
locationPicker: {
migrationPathPlaceholder: 'Enter path'
}
}
}
}
})
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue'],
template: `<input
data-testid="source-input"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>`
}
const CheckboxStub = {
props: ['modelValue', 'inputId', 'binary'],
emits: ['update:modelValue', 'click'],
template: `<input
type="checkbox"
:data-testid="'checkbox-' + inputId"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
@click.stop="$emit('click')"
/>`
}
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
return render(MigrationPicker, {
props: { sourcePath, migrationItemIds },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: {
props: ['severity'],
template: '<div data-testid="error-msg"><slot /></div>'
}
}
}
})
}
describe('MigrationPicker', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('isValidSource', () => {
it('hides migration options when source path is empty', () => {
renderPicker('')
expect(screen.queryByText('Select items to migrate')).toBeNull()
})
it('shows migration options when source path is valid', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const { rerender } = renderPicker('')
await rerender({ sourcePath: '/valid/path' })
await waitFor(() => {
expect(screen.getByText('Select items to migrate')).toBeDefined()
})
})
it('shows optional message when no valid source', () => {
renderPicker('')
expect(screen.getByText('Migration is optional')).toBeDefined()
})
})
describe('validateSource', () => {
it('clears error when source path becomes empty', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
await user.clear(screen.getByTestId('source-input'))
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
it('shows error message when validation fails', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Path not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
})
it('shows no error when validation passes', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/valid/path')
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
})
describe('migrationItemIds watchEffect', () => {
it('emits all item IDs by default (all items start selected)', async () => {
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
migrationItemIds: [],
'onUpdate:migrationItemIds': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button />' },
Message: { template: '<div />' }
}
}
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
const emittedIds = onUpdate.mock.calls[0][0]
expect(Array.isArray(emittedIds)).toBe(true)
expect(emittedIds.length).toBeGreaterThan(0)
})
})
})
describe('browse path', () => {
it('opens directory picker on browse click', async () => {
mockShowDirectoryPicker.mockResolvedValue(null)
renderPicker()
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
})
it('updates source path when directory is selected', async () => {
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
'onUpdate:sourcePath': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: { template: '<div />' }
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
})
})
})
})

View File

@@ -1,85 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import StatusTag from '@/components/maintenance/StatusTag.vue'
const TagStub = defineComponent({
name: 'Tag',
props: {
icon: String,
severity: String,
value: String
},
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
})
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
return render(StatusTag, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: { Tag: TagStub }
}
})
}
describe('StatusTag', () => {
describe('refreshing state', () => {
it('shows info severity when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
})
it('shows refreshing translation key when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
it('shows question icon when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
})
})
describe('error state', () => {
it('shows danger severity when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
})
it('shows error translation key when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
})
})
describe('OK state', () => {
it('shows success severity when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
})
it('shows OK translation key when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
})
})
describe('precedence', () => {
it('shows refreshing state when both refreshing and error are true', () => {
renderStatusTag({ error: true, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
})
})

View File

@@ -1,89 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskCard from '@/components/maintenance/TaskCard.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
shortDescription: 'Short description',
errorDescription: 'Error occurred',
execute: vi.fn().mockResolvedValue(true)
}
const cardStubs = {
Card: {
template: '<div data-testid="card"><slot name="content"></slot></div>'
},
Button: { template: '<button />' }
}
function renderCard(
state: 'OK' | 'error' | 'warning' | 'skipped',
task: MaintenanceTask = baseTask
) {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskCard, {
props: { task },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: cardStubs
}
})
}
describe('TaskCard', () => {
describe('description computed', () => {
it('shows errorDescription when task state is error', () => {
renderCard('error')
expect(screen.getByText('Error occurred')).toBeDefined()
})
it('shows shortDescription when task state is OK', () => {
renderCard('OK')
expect(screen.getByText('Short description')).toBeDefined()
})
it('shows shortDescription when task state is warning', () => {
renderCard('warning')
expect(screen.getByText('Short description')).toBeDefined()
})
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
const taskWithoutErrorDesc: MaintenanceTask = {
...baseTask,
errorDescription: undefined
}
renderCard('error', taskWithoutErrorDesc)
expect(screen.getByText('Short description')).toBeDefined()
})
})
})

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