mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
51 Commits
sno-qa-107
...
architectu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef8133a53 | ||
|
|
9f7646309c | ||
|
|
28797da9c0 | ||
|
|
98e3742ada | ||
|
|
e2b93bd0e5 | ||
|
|
997f4995dc | ||
|
|
0d71e6757a | ||
|
|
9b5c24b0ed | ||
|
|
418ae5b767 | ||
|
|
98c1ffc5de | ||
|
|
a8670fed6e | ||
|
|
5d3b074f9d | ||
|
|
62a34952f1 | ||
|
|
fcb7d914f3 | ||
|
|
5954b799dd | ||
|
|
440b3280b6 | ||
|
|
8aef477ce7 | ||
|
|
c33ab2f155 | ||
|
|
63022511a2 | ||
|
|
9a2eb88d6c | ||
|
|
bb5713d4c3 | ||
|
|
ddf0256695 | ||
|
|
7ff4938ed6 | ||
|
|
16b262cbb5 | ||
|
|
b55a5582fd | ||
|
|
0d19d8646c | ||
|
|
8449a496ca | ||
|
|
b6d8836d14 | ||
|
|
5dea2d0f1b | ||
|
|
0505bda1ce | ||
|
|
c78ad2f2c6 | ||
|
|
fcdb1237d2 | ||
|
|
3428466eff | ||
|
|
f61bde18dc | ||
|
|
eb4be6052e | ||
|
|
0dca2d5b05 | ||
|
|
caf98def1b | ||
|
|
8319e74f7b | ||
|
|
6e06280642 | ||
|
|
716629adbc | ||
|
|
8779a1e4f8 | ||
|
|
4a5e409d7f | ||
|
|
70c3f88f1f | ||
|
|
7db077534e | ||
|
|
0006815ebf | ||
|
|
629513579e | ||
|
|
06739fc4b0 | ||
|
|
79a2f577c0 | ||
|
|
bed2c2fdab | ||
|
|
e6901a32a3 | ||
|
|
97c61eeff3 |
@@ -1,278 +0,0 @@
|
||||
---
|
||||
name: reproduce-issue
|
||||
description: 'Reproduce a GitHub issue by researching prerequisites, setting up the environment (custom nodes, workflows, settings), and interactively exploring ComfyUI via playwright-cli until the bug is confirmed. Then records a clean demo video.'
|
||||
---
|
||||
|
||||
# Issue Reproduction Skill
|
||||
|
||||
Reproduce a reported GitHub issue against a running ComfyUI instance. This skill uses an interactive, agent-driven approach — not a static script. You will research, explore, retry, and adapt until the bug is reproduced, then record a clean demo.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two videos are produced:
|
||||
|
||||
1. **Research video** — the full exploration session: installing deps, trying things, failing, retrying, figuring out the bug. Valuable for debugging context.
|
||||
2. **Reproduce video** — a clean, minimal recording of just the reproduction steps. This is the demo you'd attach to the issue.
|
||||
|
||||
```
|
||||
Phase 1: Research → Read issue, understand prerequisites
|
||||
Phase 2: Environment → Install custom nodes, load workflows, configure settings
|
||||
Phase 3: Explore → [VIDEO 1: research] Interactively try to reproduce (retries OK)
|
||||
Phase 4: Record → [VIDEO 2: reproduce] Clean recording of just the minimal repro steps
|
||||
Phase 5: Report → Generate a structured reproduction report
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ComfyUI server running (ask user for URL, default: `http://127.0.0.1:8188`)
|
||||
- `playwright-cli` installed: `npm install -g @playwright/cli@latest`
|
||||
- `gh` CLI (authenticated, for reading issues)
|
||||
- ComfyUI backend with Python environment (for installing custom nodes)
|
||||
|
||||
## Phase 1: Research the Issue
|
||||
|
||||
1. Fetch the issue details:
|
||||
|
||||
```bash
|
||||
gh issue view <number> --repo Comfy-Org/ComfyUI_frontend --json title,body,comments
|
||||
```
|
||||
|
||||
2. Extract from the issue body:
|
||||
- **Reproduction steps** (the exact sequence)
|
||||
- **Prerequisites**: specific workflows, custom nodes, settings, models
|
||||
- **Environment**: OS, browser, ComfyUI version
|
||||
- **Media**: screenshots or videos showing the bug
|
||||
|
||||
3. Search the codebase for related code:
|
||||
- Find the feature/component mentioned in the issue
|
||||
- Understand how it works currently
|
||||
- Identify what state the UI needs to be in
|
||||
|
||||
## Phase 2: Environment Setup
|
||||
|
||||
Set up everything the issue requires BEFORE attempting reproduction.
|
||||
|
||||
### Custom Nodes
|
||||
|
||||
If the issue mentions custom nodes:
|
||||
|
||||
```bash
|
||||
# Find the custom node repo
|
||||
# Clone into ComfyUI's custom_nodes directory
|
||||
cd <comfyui_path>/custom_nodes
|
||||
git clone <custom_node_repo_url>
|
||||
|
||||
# Install dependencies if needed
|
||||
cd <custom_node_name>
|
||||
pip install -r requirements.txt 2>/dev/null || true
|
||||
|
||||
# Restart ComfyUI server to load the new nodes
|
||||
```
|
||||
|
||||
### Workflows
|
||||
|
||||
If the issue references a specific workflow:
|
||||
|
||||
```bash
|
||||
# Download workflow JSON if a URL is provided
|
||||
curl -L "<workflow_url>" -o /tmp/test-workflow.json
|
||||
|
||||
# Load it via the API
|
||||
curl -X POST http://127.0.0.1:8188/api/workflow \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/test-workflow.json
|
||||
```
|
||||
|
||||
Or load via playwright-cli:
|
||||
|
||||
```bash
|
||||
playwright-cli goto "http://127.0.0.1:8188"
|
||||
# Drag-and-drop or use File > Open to load the workflow
|
||||
```
|
||||
|
||||
### Settings
|
||||
|
||||
If the issue requires specific settings:
|
||||
|
||||
```bash
|
||||
# Use playwright-cli to open settings and change them
|
||||
playwright-cli press "Control+,"
|
||||
playwright-cli snapshot
|
||||
# Find and modify the relevant setting
|
||||
```
|
||||
|
||||
## Phase 3: Interactive Exploration — Research Video
|
||||
|
||||
Start recording the **research video** (Video 1). This captures the full exploration — mistakes, retries, dead ends — all valuable context.
|
||||
|
||||
```bash
|
||||
# Open browser and start video recording
|
||||
playwright-cli open "http://127.0.0.1:8188"
|
||||
playwright-cli video-start
|
||||
|
||||
# Take a snapshot to see current state
|
||||
playwright-cli snapshot
|
||||
|
||||
# Interact based on what you see
|
||||
playwright-cli click <ref>
|
||||
playwright-cli fill <ref> "text"
|
||||
playwright-cli press "Control+s"
|
||||
|
||||
# Check results
|
||||
playwright-cli snapshot
|
||||
playwright-cli screenshot --filename=/tmp/qa/research-step-1.png
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
- **Observe before acting**: Always `snapshot` before interacting
|
||||
- **Retry and adapt**: If a step fails, try a different approach
|
||||
- **Document what works**: Keep notes on which steps trigger the bug
|
||||
- **Don't give up**: Try multiple approaches if the first doesn't work
|
||||
- **Establish prerequisites**: Many bugs require specific UI state:
|
||||
- Save a workflow first (File > Save)
|
||||
- Make changes to dirty the workflow
|
||||
- Open multiple tabs
|
||||
- Add specific node types
|
||||
- Change settings
|
||||
- Resize the window
|
||||
|
||||
### Common ComfyUI Interactions via playwright-cli
|
||||
|
||||
| Action | Command |
|
||||
| ------------------- | -------------------------------------------------------------- |
|
||||
| Open hamburger menu | `playwright-cli click` on the C logo button |
|
||||
| Navigate menu | `playwright-cli hover <ref>` then `playwright-cli click <ref>` |
|
||||
| Add node | Double-click canvas → type node name → select from results |
|
||||
| Connect nodes | Drag from output slot to input slot |
|
||||
| Save workflow | `playwright-cli press "Control+s"` |
|
||||
| Save As | Menu > File > Save As |
|
||||
| Select node | Click on the node |
|
||||
| Delete node | Select → `playwright-cli press "Delete"` |
|
||||
| Right-click menu | `playwright-cli click <ref> --button right` |
|
||||
| Keyboard shortcut | `playwright-cli press "Control+z"` |
|
||||
|
||||
## Phase 4: Record Clean Demo — Reproduce Video (max 5 minutes)
|
||||
|
||||
Once the bug is confirmed, **stop the research video** and **close the research browser**:
|
||||
|
||||
```bash
|
||||
playwright-cli video-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
Now start a **fresh browser session** for the clean reproduce video (Video 2).
|
||||
|
||||
**IMPORTANT constraints:**
|
||||
|
||||
- **Max 5 minutes** — the reproduce video must be short and focused
|
||||
- **No environment setup** — server, user, custom nodes are already set up from Phase 3. Just log in and go.
|
||||
- **No exploration** — you already know the exact steps. Execute them quickly and precisely.
|
||||
- **Start video recording immediately**, execute steps, stop. Don't leave the recording running while thinking.
|
||||
|
||||
1. **Open browser and start recording**:
|
||||
|
||||
```bash
|
||||
playwright-cli open "http://127.0.0.1:8188"
|
||||
playwright-cli video-start
|
||||
```
|
||||
|
||||
2. **Execute only the minimal reproduction steps** — no exploration, no mistakes. Just the clean sequence that demonstrates the bug. You already know exactly what works from Phase 3.
|
||||
|
||||
3. **Take key screenshots** at critical moments:
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot --filename=/tmp/qa/before-bug.png
|
||||
# ... trigger the bug ...
|
||||
playwright-cli screenshot --filename=/tmp/qa/bug-visible.png
|
||||
```
|
||||
|
||||
4. **Stop recording and close** immediately after the bug is demonstrated:
|
||||
```bash
|
||||
playwright-cli video-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Phase 5: Generate Report
|
||||
|
||||
Create a reproduction report at `tmp/qa/reproduce-report.md`:
|
||||
|
||||
```markdown
|
||||
# Issue Reproduction Report
|
||||
|
||||
- **Issue**: <issue_url>
|
||||
- **Title**: <issue_title>
|
||||
- **Date**: <today>
|
||||
- **Status**: Reproduced / Not Reproduced / Partially Reproduced
|
||||
|
||||
## Environment
|
||||
|
||||
- ComfyUI Server: <url>
|
||||
- OS: <os>
|
||||
- Custom Nodes Installed: <list or "none">
|
||||
- Settings Changed: <list or "none">
|
||||
|
||||
## Prerequisites
|
||||
|
||||
List everything that had to be set up before the bug could be triggered:
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
Minimal steps to reproduce (the clean sequence):
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<from the issue>
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<what actually happened>
|
||||
|
||||
## Evidence
|
||||
|
||||
- Research video: `research-video/video.webm` (full exploration session)
|
||||
- Reproduce video: `reproduce-video/video.webm` (clean minimal repro)
|
||||
- Screenshots: `before-bug.png`, `bug-visible.png`
|
||||
|
||||
## Root Cause Analysis (if identified)
|
||||
|
||||
<code pointers, hypothesis about what's going wrong>
|
||||
|
||||
## Notes
|
||||
|
||||
<any additional observations, workarounds discovered, related issues>
|
||||
```
|
||||
|
||||
## Handling Failures
|
||||
|
||||
If the bug **cannot be reproduced**:
|
||||
|
||||
1. Document what you tried and why it didn't work
|
||||
2. Check if the issue was already fixed (search git log for related commits)
|
||||
3. Check if it's environment-specific (OS, browser, specific version)
|
||||
4. Set report status to "Not Reproduced" with detailed notes
|
||||
5. The report is still valuable — it saves others from repeating the same investigation
|
||||
|
||||
## CI Integration
|
||||
|
||||
In CI, this skill runs as a Claude Code agent with:
|
||||
|
||||
- `ANTHROPIC_API_KEY` for Claude
|
||||
- `GEMINI_API_KEY` for initial issue analysis (optional)
|
||||
- ComfyUI server pre-started in the container
|
||||
- `playwright-cli` pre-installed
|
||||
|
||||
The CI workflow:
|
||||
|
||||
1. Gemini generates a reproduce guide (markdown) from the issue
|
||||
2. Claude agent receives the guide and runs this skill
|
||||
3. Claude explores interactively, installs dependencies, retries
|
||||
4. Claude records a clean demo once reproduced
|
||||
5. Video and report are uploaded as artifacts
|
||||
@@ -1,277 +0,0 @@
|
||||
---
|
||||
name: comfy-qa
|
||||
description: 'Comprehensive QA of ComfyUI frontend. Navigates all routes, tests all interactive features using playwright-cli, generates a report, and submits a draft PR. Works in CI and local environments, cross-platform.'
|
||||
---
|
||||
|
||||
# ComfyUI Frontend QA Skill
|
||||
|
||||
Automated quality assurance for the ComfyUI frontend. The pipeline reproduces reported bugs using Playwright E2E tests, records video evidence, and deploys reports to Cloudflare Pages.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The QA pipeline uses a **three-phase approach**:
|
||||
|
||||
1. **RESEARCH** — Claude writes Playwright E2E tests to reproduce bugs (assertion-backed, no hallucination)
|
||||
2. **REPRODUCE** — Deterministic replay of the research test with video recording
|
||||
3. **REPORT** — Deploy results to Cloudflare Pages with badge, video, and verdict
|
||||
|
||||
### Key Design Decision
|
||||
|
||||
Earlier iterations used AI vision (Gemini) to drive a browser and judge results from video. This was abandoned after discovering **AI reviewers hallucinate** — Gemini reported "REPRODUCED" when videos showed idle screens. The current approach uses **Playwright assertions** as the source of truth: if the test passes, the bug is proven.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- `pnpm` package manager
|
||||
- `gh` CLI (authenticated)
|
||||
- Playwright browsers: `npx playwright install chromium`
|
||||
- Environment variables:
|
||||
- `GEMINI_API_KEY` — for PR analysis and video review
|
||||
- `ANTHROPIC_API_KEY` — for Claude Agent SDK (research phase)
|
||||
- `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` — for report deployment
|
||||
|
||||
## Pipeline Scripts
|
||||
|
||||
| Script | Role | Model |
|
||||
|---|---|---|
|
||||
| `scripts/qa-analyze-pr.ts` | Deep PR/issue analysis → QA guide | gemini-3.1-pro-preview |
|
||||
| `scripts/qa-agent.ts` | Research phase: Claude writes E2E tests | claude-sonnet-4-6 (Agent SDK) |
|
||||
| `scripts/qa-record.ts` | Before/after video recording with Gemini-driven actions | gemini-3.1-pro-preview |
|
||||
| `scripts/qa-reproduce.ts` | Deterministic replay with narration | gemini-3-flash-preview |
|
||||
| `scripts/qa-video-review.ts` | Video comparison review | gemini-3-flash-preview |
|
||||
| `scripts/qa-generate-test.ts` | Regression test generation from QA report | gemini-3-flash-preview |
|
||||
| `scripts/qa-deploy-pages.sh` | Deploy to Cloudflare Pages + badge | — |
|
||||
| `scripts/qa-batch.sh` | Batch-trigger QA for multiple issues | — |
|
||||
| `scripts/qa-report-template.html` | Report site (light/dark, seekbar, copy badge) | — |
|
||||
|
||||
## Triggering QA
|
||||
|
||||
### Via GitHub Labels
|
||||
|
||||
- **`qa-changes`** — Focused QA on a PR (Linux-only, before/after comparison)
|
||||
- **`qa-full`** — Full QA (3-OS matrix, after-only)
|
||||
- **`qa-issue`** — Reproduce a bug from an issue
|
||||
|
||||
### Via Batch Script
|
||||
|
||||
```bash
|
||||
# Trigger QA for specific issue numbers
|
||||
./scripts/qa-batch.sh 10394 10238 9996
|
||||
|
||||
# From a triage file (top 5 Tier 1 issues)
|
||||
./scripts/qa-batch.sh --from tmp/issues.md --top 5
|
||||
|
||||
# Preview without pushing
|
||||
./scripts/qa-batch.sh --dry-run 10394
|
||||
|
||||
# Clean up old trigger branches
|
||||
./scripts/qa-batch.sh --cleanup
|
||||
```
|
||||
|
||||
### Via Workflow Dispatch
|
||||
|
||||
Go to Actions → "PR: QA" → Run workflow → choose mode (focused/full).
|
||||
|
||||
## CI Workflow (`.github/workflows/pr-qa.yaml`)
|
||||
|
||||
```
|
||||
resolve-matrix → analyze-pr ──┐
|
||||
├→ qa-before (main branch, worktree build)
|
||||
├→ qa-after (PR branch)
|
||||
└→ report (video review, deploy, comment)
|
||||
```
|
||||
|
||||
Before/after jobs run **in parallel** on separate runners for clean isolation.
|
||||
|
||||
### Issue Reproduce Mode
|
||||
|
||||
For issues (not PRs), the pipeline:
|
||||
1. Fetches the issue body and comments
|
||||
2. Runs `qa-analyze-pr.ts --type issue` to generate a QA guide
|
||||
3. Runs the research phase (Claude writes E2E test to reproduce)
|
||||
4. Records video of the test execution
|
||||
5. Posts results as a comment on the issue
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Step 1: Environment Setup
|
||||
|
||||
```bash
|
||||
# Ensure ComfyUI server is running
|
||||
# Default: http://127.0.0.1:8188
|
||||
|
||||
# Install Playwright browsers
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### Step 2: Analyze the Issue/PR
|
||||
|
||||
```bash
|
||||
# For a PR
|
||||
pnpm exec tsx scripts/qa-analyze-pr.ts \
|
||||
--pr-number 10394 \
|
||||
--repo Comfy-Org/ComfyUI_frontend \
|
||||
--output-dir qa-guides
|
||||
|
||||
# For an issue
|
||||
pnpm exec tsx scripts/qa-analyze-pr.ts \
|
||||
--pr-number 10394 \
|
||||
--repo Comfy-Org/ComfyUI_frontend \
|
||||
--output-dir qa-guides \
|
||||
--type issue
|
||||
```
|
||||
|
||||
### Step 3: Record Before/After
|
||||
|
||||
```bash
|
||||
# Before (main branch)
|
||||
pnpm exec tsx scripts/qa-record.ts \
|
||||
--mode before \
|
||||
--diff /tmp/pr-diff.txt \
|
||||
--output-dir /tmp/qa-before \
|
||||
--qa-guide qa-guides/qa-guide-1.json
|
||||
|
||||
# After (PR branch)
|
||||
pnpm exec tsx scripts/qa-record.ts \
|
||||
--mode after \
|
||||
--diff /tmp/pr-diff.txt \
|
||||
--output-dir /tmp/qa-after \
|
||||
--qa-guide qa-guides/qa-guide-1.json
|
||||
```
|
||||
|
||||
### Step 4: Review Videos
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/qa-video-review.ts \
|
||||
--artifacts-dir /tmp/qa-artifacts \
|
||||
--video-file qa-session.mp4 \
|
||||
--before-video qa-before-session.mp4 \
|
||||
--output-dir /tmp/video-reviews \
|
||||
--pr-context /tmp/pr-context.txt
|
||||
```
|
||||
|
||||
## Research Phase Details (`qa-agent.ts`)
|
||||
|
||||
Claude receives:
|
||||
- The issue description and comments
|
||||
- A QA guide from `qa-analyze-pr.ts`
|
||||
- An accessibility tree snapshot of the current UI
|
||||
|
||||
Claude's tools:
|
||||
- **`inspect(selector?)`** — Read a11y tree to discover element selectors
|
||||
- **`writeTest(code)`** — Write a Playwright `.spec.ts` file
|
||||
- **`runTest()`** — Execute the test and get pass/fail + errors
|
||||
- **`done(verdict, summary, evidence, testCode)`** — Finish with verdict
|
||||
|
||||
The test uses the project's Playwright fixtures (`comfyPageFixture`), giving access to `comfyPage.page`, `comfyPage.menu`, `comfyPage.settings`, etc.
|
||||
|
||||
### Verdict Logic
|
||||
|
||||
- **REPRODUCED** — Test passes (asserting the bug exists) → bug is proven
|
||||
- **NOT_REPRODUCIBLE** — Claude exhausted attempts, test cannot pass
|
||||
- **INCONCLUSIVE** — Agent timed out or encountered infrastructure issues
|
||||
|
||||
Auto-completion: if a test passed but `done()` was never called, the pipeline auto-completes with REPRODUCED.
|
||||
|
||||
## Manual QA (Fallback)
|
||||
|
||||
When the automated pipeline isn't suitable (e.g., visual-only bugs, complex multi-step interactions), use **playwright-cli** for manual browser interaction:
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install -g @playwright/cli@latest
|
||||
|
||||
# Open browser and navigate
|
||||
playwright-cli open http://127.0.0.1:8188
|
||||
|
||||
# Get element references
|
||||
playwright-cli snapshot
|
||||
|
||||
# Interact
|
||||
playwright-cli click e1
|
||||
playwright-cli fill e2 "test text"
|
||||
playwright-cli press Escape
|
||||
playwright-cli screenshot --filename=f.png
|
||||
```
|
||||
|
||||
Snapshots return element references (`e1`, `e2`, …). Always run `snapshot` after navigation to refresh refs.
|
||||
|
||||
## Manual QA Test Plan
|
||||
|
||||
When performing manual QA (either via playwright-cli or the automated pipeline), systematically test each area below.
|
||||
|
||||
### Application Load & Routes
|
||||
|
||||
| Test | Steps |
|
||||
|---|---|
|
||||
| Root route loads | Navigate to `/` — GraphView should render with canvas |
|
||||
| User select route | Navigate to `/user-select` — user selection UI should appear |
|
||||
| 404 handling | Navigate to `/nonexistent` — should handle gracefully |
|
||||
|
||||
### Canvas & Graph View
|
||||
|
||||
| Test | Steps |
|
||||
|---|---|
|
||||
| Canvas renders | The LiteGraph canvas is visible and interactive |
|
||||
| Pan canvas | Click and drag on empty canvas area |
|
||||
| Zoom in/out | Use scroll wheel or Alt+=/Alt+- |
|
||||
| Add node via double-click | Double-click canvas to open search, type "KSampler", select it |
|
||||
| Delete node | Select a node, press Delete key |
|
||||
| Connect nodes | Drag from output slot to input slot |
|
||||
| Copy/Paste | Select nodes, Ctrl+C then Ctrl+V |
|
||||
| Undo/Redo | Make changes, Ctrl+Z to undo, Ctrl+Y to redo |
|
||||
| Context menus | Right-click node vs empty canvas — different menus |
|
||||
|
||||
### Sidebar Tabs
|
||||
|
||||
| Test | Steps |
|
||||
|---|---|
|
||||
| Workflows tab | Press W — workflows sidebar opens |
|
||||
| Node Library tab | Press N — node library opens |
|
||||
| Model Library tab | Press M — model library opens |
|
||||
| Tab toggle | Press same key again — sidebar closes |
|
||||
| Search in sidebar | Type in search box — results filter |
|
||||
|
||||
### Settings Dialog
|
||||
|
||||
| Test | Steps |
|
||||
|---|---|
|
||||
| Open settings | Press Ctrl+, or click settings button |
|
||||
| Change a setting | Toggle a boolean setting — it persists after closing |
|
||||
| Search settings | Type in settings search box — results filter |
|
||||
| Close settings | Press Escape or click close button |
|
||||
|
||||
### Execution & Queue
|
||||
|
||||
| Test | Steps |
|
||||
|---|---|
|
||||
| Queue prompt | Load default workflow, click Queue — execution starts |
|
||||
| Queue progress | Progress indicator shows during execution |
|
||||
| Interrupt | Press Ctrl+Alt+Enter during execution — interrupts |
|
||||
|
||||
## Report Site
|
||||
|
||||
Deployed to Cloudflare Pages at `https://comfy-qa.pages.dev/<branch>/`.
|
||||
|
||||
Features:
|
||||
- Light/dark theme
|
||||
- Seekable video player with preload
|
||||
- Copy badge button (markdown)
|
||||
- Date-stamped badges (e.g., `QA0327`)
|
||||
- Vertical box badge for issues and PRs
|
||||
|
||||
## Known Issues & Troubleshooting
|
||||
|
||||
See `docs/qa/TROUBLESHOOTING.md` for common failures:
|
||||
- `set -euo pipefail` + grep with no match → append `|| true`
|
||||
- `__name is not defined` in `page.evaluate` → use `addScriptTag`
|
||||
- Cursor not visible in videos → monkey-patch `page.mouse` methods
|
||||
- Agent not calling `done()` → auto-complete from passing test
|
||||
|
||||
## Backlog
|
||||
|
||||
See `docs/qa/backlog.md` for planned improvements:
|
||||
- **Type B comparison**: Different commits for regression detection
|
||||
- **Type C comparison**: Cross-browser testing
|
||||
- **Pre-seed assets**: Upload test images before recording
|
||||
- **Lazy a11y tree**: Reduce token usage with `inspect(selector)` vs full dump
|
||||
11
.github/actions/setup-comfyui-server/action.yaml
vendored
11
.github/actions/setup-comfyui-server/action.yaml
vendored
@@ -44,17 +44,12 @@ runs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
|
||||
- name: Start ComfyUI server
|
||||
if: ${{ inputs.launch_server == 'true' }}
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
env:
|
||||
EXTRA_SERVER_PARAMS: ${{ inputs.extra_server_params }}
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist $EXTRA_SERVER_PARAMS &
|
||||
for i in $(seq 1 300); do
|
||||
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready" && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::ComfyUI server did not start within 600s" && exit 1
|
||||
python main.py --cpu --multi-user --front-end-root ../dist ${{ inputs.extra_server_params }} &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
|
||||
@@ -95,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 ''
|
||||
|
||||
13
.github/workflows/ci-tests-unit.yaml
vendored
13
.github/workflows/ci-tests-unit.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
# Description: Unit and component testing with Vitest + coverage reporting
|
||||
# Description: Unit and component testing with Vitest
|
||||
name: 'CI: Tests Unit'
|
||||
|
||||
on:
|
||||
@@ -23,12 +23,5 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
fail_ci_if_error: false
|
||||
- name: Run Vitest tests
|
||||
run: pnpm test:unit
|
||||
|
||||
1092
.github/workflows/pr-qa.yaml
vendored
1092
.github/workflows/pr-qa.yaml
vendored
File diff suppressed because it is too large
Load Diff
6
.gitignore
vendored
6
.gitignore
vendored
@@ -66,6 +66,7 @@ dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
.superpowers/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
@@ -99,7 +100,4 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.playwright-cli/
|
||||
.playwright/
|
||||
.claude/scheduled_tasks.lock
|
||||
.amp
|
||||
@@ -9,7 +9,6 @@
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/__fixtures__/**/*.json",
|
||||
"scripts/qa-report-template.html"
|
||||
"**/__fixtures__/**/*.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -216,7 +216,6 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
|
||||
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
|
||||
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
|
||||
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
|
||||
|
||||
## External Resources
|
||||
|
||||
|
||||
38
CODEOWNERS
38
CODEOWNERS
@@ -41,46 +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
|
||||
/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
|
||||
|
||||
16
apps/architecture-adventure/index.html
Normal file
16
apps/architecture-adventure/index.html
Normal 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>
|
||||
61
apps/architecture-adventure/package.json
Normal file
61
apps/architecture-adventure/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/architecture-adventure/scripts/inline-build.ts
Normal file
39
apps/architecture-adventure/scripts/inline-build.ts
Normal 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)`)
|
||||
476
apps/architecture-adventure/src/data/challenges.ts
Normal file
476
apps/architecture-adventure/src/data/challenges.ts
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
apps/architecture-adventure/src/data/graph.ts
Normal file
13
apps/architecture-adventure/src/data/graph.ts
Normal 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
|
||||
}))
|
||||
)
|
||||
194
apps/architecture-adventure/src/data/narrative.ts
Normal file
194
apps/architecture-adventure/src/data/narrative.ts
Normal 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(' ')
|
||||
}
|
||||
430
apps/architecture-adventure/src/data/rooms.ts
Normal file
430
apps/architecture-adventure/src/data/rooms.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
15
apps/architecture-adventure/src/engine/navigation.ts
Normal file
15
apps/architecture-adventure/src/engine/navigation.ts
Normal 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 }
|
||||
107
apps/architecture-adventure/src/engine/stateMachine.ts
Normal file
107
apps/architecture-adventure/src/engine/stateMachine.ts
Normal 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
|
||||
}
|
||||
26
apps/architecture-adventure/src/main.ts
Normal file
26
apps/architecture-adventure/src/main.ts
Normal 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()
|
||||
67
apps/architecture-adventure/src/state/gameState.ts
Normal file
67
apps/architecture-adventure/src/state/gameState.ts
Normal 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 }
|
||||
36
apps/architecture-adventure/src/state/prestige.ts
Normal file
36
apps/architecture-adventure/src/state/prestige.ts
Normal 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 }
|
||||
22
apps/architecture-adventure/src/state/tags.ts
Normal file
22
apps/architecture-adventure/src/state/tags.ts
Normal 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 }
|
||||
46
apps/architecture-adventure/src/style/animations.css
Normal file
46
apps/architecture-adventure/src/style/animations.css
Normal 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;
|
||||
}
|
||||
208
apps/architecture-adventure/src/style/challenge.css
Normal file
208
apps/architecture-adventure/src/style/challenge.css
Normal 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;
|
||||
}
|
||||
92
apps/architecture-adventure/src/style/hud.css
Normal file
92
apps/architecture-adventure/src/style/hud.css
Normal 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;
|
||||
}
|
||||
}
|
||||
38
apps/architecture-adventure/src/style/layout.css
Normal file
38
apps/architecture-adventure/src/style/layout.css
Normal 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;
|
||||
}
|
||||
}
|
||||
103
apps/architecture-adventure/src/style/map.css
Normal file
103
apps/architecture-adventure/src/style/map.css
Normal 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;
|
||||
}
|
||||
118
apps/architecture-adventure/src/style/room.css
Normal file
118
apps/architecture-adventure/src/style/room.css
Normal 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);
|
||||
}
|
||||
41
apps/architecture-adventure/src/style/sidebar.css
Normal file
41
apps/architecture-adventure/src/style/sidebar.css
Normal 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;
|
||||
}
|
||||
77
apps/architecture-adventure/src/style/theme.css
Normal file
77
apps/architecture-adventure/src/style/theme.css
Normal 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;
|
||||
}
|
||||
146
apps/architecture-adventure/src/types.ts
Normal file
146
apps/architecture-adventure/src/types.ts
Normal 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
|
||||
}
|
||||
117
apps/architecture-adventure/src/ui/challengeView.ts
Normal file
117
apps/architecture-adventure/src/ui/challengeView.ts
Normal 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 }
|
||||
73
apps/architecture-adventure/src/ui/endingView.ts
Normal file
73
apps/architecture-adventure/src/ui/endingView.ts
Normal 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 }
|
||||
43
apps/architecture-adventure/src/ui/hud.ts
Normal file
43
apps/architecture-adventure/src/ui/hud.ts
Normal 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 }
|
||||
194
apps/architecture-adventure/src/ui/nodeMap.ts
Normal file
194
apps/architecture-adventure/src/ui/nodeMap.ts
Normal 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 }
|
||||
92
apps/architecture-adventure/src/ui/renderer.ts
Normal file
92
apps/architecture-adventure/src/ui/renderer.ts
Normal 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 }
|
||||
83
apps/architecture-adventure/src/ui/roomView.ts
Normal file
83
apps/architecture-adventure/src/ui/roomView.ts
Normal 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 }
|
||||
37
apps/architecture-adventure/src/ui/sidebar.ts
Normal file
37
apps/architecture-adventure/src/ui/sidebar.ts
Normal 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 }
|
||||
21
apps/architecture-adventure/tsconfig.json
Normal file
21
apps/architecture-adventure/tsconfig.json
Normal 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"]
|
||||
}
|
||||
26
apps/architecture-adventure/vite.config.ts
Normal file
26
apps/architecture-adventure/vite.config.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.label"
|
||||
class="flex items-center gap-2 text-sm text-white"
|
||||
>
|
||||
<span aria-hidden="true">{{ feature.icon }}</span>
|
||||
<span>{{ feature.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:href="card.href"
|
||||
class="flex flex-1 flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<span class="text-4xl" aria-hidden="true">{{ card.icon }}</span>
|
||||
<h3 class="mt-4 text-xl font-semibold text-white">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
<span
|
||||
class="mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
card.outlined
|
||||
? 'border border-brand-yellow text-brand-yellow'
|
||||
: 'bg-brand-yellow text-black'
|
||||
"
|
||||
>
|
||||
{{ card.cta }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,77 +0,0 @@
|
||||
<!-- TODO: Replace placeholder content with real quotes and case studies -->
|
||||
<script setup lang="ts">
|
||||
const studies = [
|
||||
{
|
||||
title: 'New Pipelines with Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'md:row-span-2'
|
||||
},
|
||||
{
|
||||
title: 'AI-Assisted Texture and Environment',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[300px] lg:col-span-2'
|
||||
},
|
||||
{
|
||||
title: 'Open-sourced the Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[200px]'
|
||||
},
|
||||
{
|
||||
title: 'Environment Generation',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: true,
|
||||
gridClass: 'min-h-[200px]'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-white">Customer Stories</h2>
|
||||
<p class="mt-2 text-smoke-700">
|
||||
See how leading studios use Comfy in production
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Bento grid -->
|
||||
<div
|
||||
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="study in studies"
|
||||
:key="study.title"
|
||||
class="flex flex-col justify-end rounded-2xl border border-brand-yellow/30 p-6"
|
||||
:class="[
|
||||
study.gridClass,
|
||||
study.highlight ? 'bg-brand-yellow' : 'bg-charcoal-600'
|
||||
]"
|
||||
>
|
||||
<h3
|
||||
class="font-semibold"
|
||||
:class="study.highlight ? 'text-black' : 'text-white'"
|
||||
>
|
||||
{{ study.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="mt-2 text-sm"
|
||||
:class="study.highlight ? 'text-black/70' : 'text-smoke-700'"
|
||||
>
|
||||
{{ study.body }}
|
||||
</p>
|
||||
<a
|
||||
href="/case-studies"
|
||||
class="mt-4 text-sm underline"
|
||||
:class="study.highlight ? 'text-black' : 'text-brand-yellow'"
|
||||
>
|
||||
READ CASE STUDY
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div v-for="(step, index) in steps" :key="step.number" class="relative">
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<span class="text-6xl font-bold text-brand-yellow/20">
|
||||
{{ step.number }}
|
||||
</span>
|
||||
<h3 class="mt-2 text-xl font-semibold text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-screen items-center overflow-hidden bg-black pt-16"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-12 px-6 md:flex-row md:gap-0"
|
||||
>
|
||||
<!-- Left: C Monogram -->
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
v-for="btn in ctaButtons"
|
||||
:key="btn.label"
|
||||
:href="btn.href"
|
||||
class="rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
btn.variant === 'solid'
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-brand-yellow text-brand-yellow'
|
||||
"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<!-- Decorative quote mark -->
|
||||
<span class="text-6xl text-brand-yellow opacity-30" aria-hidden="true">
|
||||
«
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
<div
|
||||
class="mx-auto mt-8 h-0.5 w-24 bg-brand-yellow opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,51 +0,0 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder video area -->
|
||||
<div
|
||||
class="mt-12 flex aspect-video items-center justify-center rounded-2xl border border-white/10 bg-charcoal-600"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature labels -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
'Nike',
|
||||
'HP',
|
||||
'Autodesk',
|
||||
'Apple',
|
||||
'Ubisoft',
|
||||
'Lucid',
|
||||
'Amazon',
|
||||
'Netflix',
|
||||
'Pixomondo',
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-y border-white/10 bg-black py-16">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
<div
|
||||
class="mt-10 flex flex-wrap items-center justify-center gap-4 md:gap-6"
|
||||
>
|
||||
<span
|
||||
v-for="company in logos"
|
||||
:key="company"
|
||||
class="rounded-full border border-white/10 px-6 py-2 text-sm text-smoke-700"
|
||||
>
|
||||
{{ company }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics row -->
|
||||
<div
|
||||
class="mt-14 flex flex-col items-center justify-center gap-10 sm:flex-row sm:gap-12"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="text-center">
|
||||
<p class="text-3xl font-bold text-white">{{ metric.value }}</p>
|
||||
<p class="mt-1 text-sm text-smoke-700">{{ metric.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
'Comfy has transformed our VFX pipeline. The node-based approach gives us unprecedented control over every step of the generation process.',
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'The level of control over AI generation is unmatched. We can iterate on game assets faster than ever before.',
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'We\u2019ve cut our iteration time by 70%. Comfy workflows let our team produce high-quality creative assets at scale.',
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
const filteredTestimonials = computed(() => {
|
||||
if (activeFilter.value === 'All') return testimonials
|
||||
return testimonials.filter((t) => t.industry === activeFilter.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
activeFilter === industry
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-white/10 text-smoke-700 hover:border-brand-yellow'
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Testimonial cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<article
|
||||
v-for="testimonial in filteredTestimonials"
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
<p class="mt-4 text-sm font-semibold text-white">
|
||||
{{ testimonial.name }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ testimonial.title }}, {{ testimonial.company }}
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,74 +0,0 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="flex flex-col items-center gap-12 lg:flex-row lg:gap-8">
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
class="mt-10 flex flex-col items-center gap-4"
|
||||
aria-label="Industry categories"
|
||||
>
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
:key="category"
|
||||
class="transition-colors"
|
||||
:class="
|
||||
index === activeCategory
|
||||
? 'text-2xl text-white'
|
||||
: 'text-xl text-ash-500 hover:text-white/70'
|
||||
"
|
||||
@click="activeCategory = index"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-white">
|
||||
{{ pillar.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ pillar.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../components/SiteNav.vue'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import SocialProofBar from '../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../components/GetStartedSection.vue'
|
||||
import CTASection from '../components/CTASection.vue'
|
||||
import ManifestoSection from '../components/ManifestoSection.vue'
|
||||
import AcademySection from '../components/AcademySection.vue'
|
||||
import SiteFooter from '../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../../components/SiteNav.vue'
|
||||
import HeroSection from '../../components/HeroSection.vue'
|
||||
import SocialProofBar from '../../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../../components/GetStartedSection.vue'
|
||||
import CTASection from '../../components/CTASection.vue'
|
||||
import ManifestoSection from '../../components/ManifestoSection.vue'
|
||||
import AcademySection from '../../components/AcademySection.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -12,13 +12,12 @@ browser_tests/
|
||||
│ ├── ComfyMouse.ts - Mouse interaction helper
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
|
||||
│ ├── components/ - Page object components (locators, user interactions)
|
||||
│ ├── components/ - Page object components
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes (domain-specific actions)
|
||||
│ ├── helpers/ - Focused helper classes
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
│ │ ├── KeyboardHelper.ts
|
||||
@@ -26,18 +25,11 @@ browser_tests/
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Pure utility functions (no page dependency)
|
||||
│ └── utils/ - Utility functions
|
||||
├── helpers/ - Test-specific utilities
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
### Architectural Separation
|
||||
|
||||
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
|
||||
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
|
||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
||||
|
||||
## Polling Assertions
|
||||
|
||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||
|
||||
@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
|
||||
|
||||
```typescript
|
||||
// Import the test fixture
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
// Set up test environment if needed
|
||||
@@ -148,12 +148,6 @@ Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
### Import Conventions
|
||||
|
||||
- Prefer `@e2e/*` for imports within `browser_tests/`
|
||||
- Continue using `@/*` for imports from `src/`
|
||||
- Avoid introducing new deep relative imports within `browser_tests/` when the alias is available
|
||||
|
||||
### Key Testing Patterns
|
||||
|
||||
1. **Focus elements explicitly**:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"steps": 20,
|
||||
"cfg": 8.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0
|
||||
},
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"2": {
|
||||
"class_type": "NonExistentCustomNode_XYZ_12345",
|
||||
"inputs": {
|
||||
"input1": "test"
|
||||
},
|
||||
"_meta": { "title": "Missing Node" }
|
||||
},
|
||||
"3": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
},
|
||||
"_meta": { "title": "Empty Latent Image" }
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,45 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import type {
|
||||
APIRequestContext,
|
||||
ExpectMatcherState,
|
||||
Locator,
|
||||
Page
|
||||
} from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
|
||||
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
|
||||
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
|
||||
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
|
||||
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
import { QueueHelper } from '@e2e/fixtures/helpers/QueueHelper'
|
||||
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
|
||||
import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper'
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { AssetsHelper } from './helpers/AssetsHelper'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
@@ -122,7 +124,7 @@ type KeysOfType<T, Match> = {
|
||||
}[keyof T]
|
||||
|
||||
class ConfirmDialog {
|
||||
public readonly root: Locator
|
||||
private readonly root: Locator
|
||||
public readonly delete: Locator
|
||||
public readonly overwrite: Locator
|
||||
public readonly reject: Locator
|
||||
@@ -197,7 +199,6 @@ export class ComfyPage {
|
||||
public readonly featureFlags: FeatureFlagHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
@@ -241,11 +242,10 @@ export class ComfyPage {
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.dragDrop = new DragDropHelper(page)
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
@@ -343,9 +343,8 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/** @deprecated Use standalone `assetPath` from `browser_tests/fixtures/utils/assetPath` directly. */
|
||||
public assetPath(fileName: string) {
|
||||
return assetPath(fileName)
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
|
||||
async goto() {
|
||||
@@ -359,7 +358,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return sleep(ms)
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,4 +476,49 @@ export const comfyPageFixture = base.extend<{
|
||||
}
|
||||
})
|
||||
|
||||
export { comfyExpect }
|
||||
const makeMatcher = function <T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
const value = await getValue(node)
|
||||
let assertion = expect(
|
||||
value,
|
||||
'Node is ' + (this.isNot ? '' : 'not ') + type
|
||||
)
|
||||
if (this.isNot) {
|
||||
assertion = assertion.not
|
||||
}
|
||||
await expect(async () => {
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
|
||||
await expect(async () => {
|
||||
expect(isFocused).toBe(!this.isNot)
|
||||
}).toPass(options)
|
||||
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
readonly moreOptionsButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
|
||||
this.moreOptionsButton = page.getByLabel(/More options/i).first()
|
||||
}
|
||||
|
||||
async openClearHistoryDialog() {
|
||||
await this.moreOptionsButton.click()
|
||||
|
||||
const clearHistoryAction = this.page.getByTestId(
|
||||
TestIds.queue.clearHistoryAction
|
||||
)
|
||||
await expect(clearHistoryAction).toBeVisible()
|
||||
await clearHistoryAction.click()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -175,8 +174,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
@@ -185,8 +182,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
// --- Empty state ---
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
@@ -197,169 +192,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Dismiss all visible toast notifications by clicking their close buttons. */
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
.toHaveCount(0, { timeout: 5000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async switchToImported() {
|
||||
await this.dismissToasts()
|
||||
await this.importedTab.click()
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async switchToGenerated() {
|
||||
await this.dismissToasts()
|
||||
await this.generatedTab.click()
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async openSettingsMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.settingsButton.click()
|
||||
// Wait for popover content to render
|
||||
await this.listViewOption
|
||||
.or(this.gridViewOption)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async waitForAssets(count?: number) {
|
||||
if (count !== undefined) {
|
||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
||||
} else {
|
||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from './BaseDialog'
|
||||
|
||||
export class SignInDialog extends BaseDialog {
|
||||
readonly emailInput: Locator
|
||||
readonly passwordInput: Locator
|
||||
readonly signInButton: Locator
|
||||
readonly forgotPasswordLink: Locator
|
||||
readonly apiKeyButton: Locator
|
||||
readonly termsLink: Locator
|
||||
readonly privacyLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.emailInput = this.root.locator('#comfy-org-sign-in-email')
|
||||
this.passwordInput = this.root.locator('#comfy-org-sign-in-password')
|
||||
this.signInButton = this.root.getByRole('button', { name: 'Sign in' })
|
||||
this.forgotPasswordLink = this.root.getByText('Forgot password?')
|
||||
this.apiKeyButton = this.root.getByRole('button', {
|
||||
name: 'Comfy API Key'
|
||||
})
|
||||
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
||||
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.page.evaluate(() => {
|
||||
void window.app!.extensionManager.dialog.showSignInDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
get heading() {
|
||||
return this.root.getByRole('heading').first()
|
||||
}
|
||||
|
||||
get signUpLink() {
|
||||
return this.root.getByText('Sign up', { exact: true })
|
||||
}
|
||||
|
||||
get signInLink() {
|
||||
return this.root.getByText('Sign in', { exact: true })
|
||||
}
|
||||
|
||||
get signUpEmailInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-email')
|
||||
}
|
||||
|
||||
get signUpPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-password')
|
||||
}
|
||||
|
||||
get signUpConfirmPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-confirm-password')
|
||||
}
|
||||
|
||||
get signUpButton() {
|
||||
return this.root.getByRole('button', { name: 'Sign up', exact: true })
|
||||
}
|
||||
|
||||
get apiKeyHeading() {
|
||||
return this.root.getByRole('heading', { name: 'API Key' })
|
||||
}
|
||||
|
||||
get apiKeyInput() {
|
||||
return this.root.locator('#comfy-org-api-key')
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return this.root.getByRole('button', { name: 'Back' })
|
||||
}
|
||||
|
||||
get dividerText() {
|
||||
return this.root.getByText('Or continue with')
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
# Mock Data Fixtures
|
||||
|
||||
Deterministic mock data for browser (Playwright) tests. Each fixture
|
||||
exports typed objects that conform to generated types from
|
||||
`packages/ingest-types` or Zod schemas in `src/schemas/`.
|
||||
|
||||
## Usage with `page.route()`
|
||||
|
||||
> **Note:** `comfyPageFixture` navigates to the app during `setup()`,
|
||||
> before the test body runs. Routes must be registered before navigation
|
||||
> to intercept initial page-load requests. Set up routes in a custom
|
||||
> fixture or `test.beforeEach` that runs before `comfyPage.setup()`.
|
||||
|
||||
```ts
|
||||
import { createMockNodeDefinitions } from '../fixtures/data/nodeDefinitions'
|
||||
import { mockSystemStats } from '../fixtures/data/systemStats'
|
||||
|
||||
// Extend the base set with test-specific nodes
|
||||
const nodeDefs = createMockNodeDefinitions({
|
||||
MyCustomNode: {
|
||||
/* ... */
|
||||
}
|
||||
})
|
||||
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill({ json: nodeDefs })
|
||||
)
|
||||
|
||||
await page.route('**/api/system_stats', (route) =>
|
||||
route.fulfill({ json: mockSystemStats })
|
||||
)
|
||||
```
|
||||
|
||||
## Adding new fixtures
|
||||
|
||||
1. Locate the generated type in `packages/ingest-types` or Zod schema
|
||||
in `src/schemas/` for the endpoint you need.
|
||||
2. Create a new `.ts` file here that imports and satisfies the
|
||||
corresponding TypeScript type.
|
||||
3. Keep values realistic but stable — avoid dates, random IDs, or
|
||||
values that would cause test flakiness.
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Base node definitions covering the default workflow.
|
||||
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
|
||||
*/
|
||||
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
|
||||
KSampler: {
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
seed: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 0xfffffffffffff,
|
||||
control_after_generate: true
|
||||
}
|
||||
],
|
||||
steps: ['INT', { default: 20, min: 1, max: 10000 }],
|
||||
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
|
||||
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
|
||||
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}],
|
||||
latent_image: ['LATENT', {}]
|
||||
},
|
||||
optional: {
|
||||
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Samples latents using the provided model and conditioning.',
|
||||
category: 'sampling',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CheckpointLoaderSimple: {
|
||||
input: {
|
||||
required: {
|
||||
ckpt_name: [
|
||||
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MODEL', 'CLIP', 'VAE'],
|
||||
output_is_list: [false, false, false],
|
||||
output_name: ['MODEL', 'CLIP', 'VAE'],
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads a diffusion model checkpoint.',
|
||||
category: 'loaders',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CLIPTextEncode: {
|
||||
input: {
|
||||
required: {
|
||||
text: ['STRING', { multiline: true, dynamicPrompts: true }],
|
||||
clip: ['CLIP', {}]
|
||||
}
|
||||
},
|
||||
output: ['CONDITIONING'],
|
||||
output_is_list: [false],
|
||||
output_name: ['CONDITIONING'],
|
||||
name: 'CLIPTextEncode',
|
||||
display_name: 'CLIP Text Encode (Prompt)',
|
||||
description: 'Encodes a text prompt using a CLIP model.',
|
||||
category: 'conditioning',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
EmptyLatentImage: {
|
||||
input: {
|
||||
required: {
|
||||
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'EmptyLatentImage',
|
||||
display_name: 'Empty Latent Image',
|
||||
description: 'Creates an empty latent image of the specified dimensions.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
VAEDecode: {
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT', {}],
|
||||
vae: ['VAE', {}]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_is_list: [false],
|
||||
output_name: ['IMAGE'],
|
||||
name: 'VAEDecode',
|
||||
display_name: 'VAE Decode',
|
||||
description: 'Decodes latent images back into pixel space.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
SaveImage: {
|
||||
input: {
|
||||
required: {
|
||||
images: ['IMAGE', {}],
|
||||
filename_prefix: ['STRING', { default: 'ComfyUI' }]
|
||||
}
|
||||
},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
name: 'SaveImage',
|
||||
display_name: 'Save Image',
|
||||
description: 'Saves images to the output directory.',
|
||||
category: 'image',
|
||||
output_node: true,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockNodeDefinitions(
|
||||
overrides?: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const base = structuredClone(baseNodeDefinitions)
|
||||
return overrides ? { ...base, ...overrides } : base
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { SystemStatsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
export const mockSystemStats: SystemStatsResponse = {
|
||||
system: {
|
||||
os: 'posix',
|
||||
python_version: '3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0]',
|
||||
embedded_python: false,
|
||||
comfyui_version: '0.3.10',
|
||||
pytorch_version: '2.4.0+cu124',
|
||||
argv: ['main.py', '--listen', '0.0.0.0'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
name: 'NVIDIA GeForce RTX 4090',
|
||||
type: 'cuda',
|
||||
vram_total: 25769803776,
|
||||
vram_free: 23622320128
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,63 +5,6 @@ import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/j
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now() / 1000
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now() / 1000
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60,
|
||||
execution_start_time: now - i * 60,
|
||||
execution_end_time: now - i * 60 + 5 + i,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
import { assetPath } from '../utils/paths'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly assetPath: (fileName: string) => string
|
||||
) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
@@ -47,7 +49,7 @@ export class DragDropHelper {
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = assetPath(fileName)
|
||||
const filePath = this.assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
|
||||
@@ -30,8 +30,6 @@ export interface PerfMeasurement {
|
||||
eventListeners: number
|
||||
totalBlockingTimeMs: number
|
||||
frameDurationMs: number
|
||||
p95FrameDurationMs: number
|
||||
allFrameDurationsMs: number[]
|
||||
}
|
||||
|
||||
export class PerformanceHelper {
|
||||
@@ -103,13 +101,13 @@ export class PerformanceHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure individual frame durations via rAF timing over a sample window.
|
||||
* Returns all per-frame durations so callers can compute avg, p95, etc.
|
||||
* Measure average frame duration via rAF timing over a sample window.
|
||||
* Returns average ms per frame (lower = better, 16.67 = 60fps).
|
||||
*/
|
||||
private async measureFrameDurations(sampleFrames = 30): Promise<number[]> {
|
||||
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
|
||||
return this.page.evaluate((frames) => {
|
||||
return new Promise<number[]>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 5000)
|
||||
return new Promise<number>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(0), 5000)
|
||||
const timestamps: number[] = []
|
||||
let count = 0
|
||||
function tick(ts: number) {
|
||||
@@ -120,14 +118,11 @@ export class PerformanceHelper {
|
||||
} else {
|
||||
clearTimeout(timeout)
|
||||
if (timestamps.length < 2) {
|
||||
resolve([])
|
||||
resolve(0)
|
||||
return
|
||||
}
|
||||
const durations: number[] = []
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
durations.push(timestamps[i] - timestamps[i - 1])
|
||||
}
|
||||
resolve(durations)
|
||||
const total = timestamps[timestamps.length - 1] - timestamps[0]
|
||||
resolve(total / (timestamps.length - 1))
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
@@ -182,21 +177,11 @@ export class PerformanceHelper {
|
||||
return after[key] - before[key]
|
||||
}
|
||||
|
||||
const [totalBlockingTimeMs, allFrameDurationsMs] = await Promise.all([
|
||||
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
|
||||
this.collectTBT(),
|
||||
this.measureFrameDurations()
|
||||
this.measureFrameDuration()
|
||||
])
|
||||
|
||||
const frameDurationMs =
|
||||
allFrameDurationsMs.length > 0
|
||||
? allFrameDurationsMs.reduce((a, b) => a + b, 0) /
|
||||
allFrameDurationsMs.length
|
||||
: 0
|
||||
|
||||
const sorted = [...allFrameDurationsMs].sort((a, b) => a - b)
|
||||
const p95FrameDurationMs =
|
||||
sorted.length > 0 ? sorted[Math.ceil(sorted.length * 0.95) - 1] : 0
|
||||
|
||||
return {
|
||||
name,
|
||||
durationMs: delta('Timestamp') * 1000,
|
||||
@@ -212,9 +197,7 @@ export class PerformanceHelper {
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
eventListeners: delta('JSEventListeners'),
|
||||
totalBlockingTimeMs,
|
||||
frameDurationMs,
|
||||
p95FrameDurationMs,
|
||||
allFrameDurationsMs
|
||||
frameDurationMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { assetPath } from '../utils/paths'
|
||||
|
||||
type FolderStructure = {
|
||||
[key: string]: FolderStructure | string
|
||||
@@ -21,7 +20,7 @@ export class WorkflowHelper {
|
||||
|
||||
for (const [key, value] of Object.entries(structure)) {
|
||||
if (typeof value === 'string') {
|
||||
const filePath = assetPath(value)
|
||||
const filePath = this.comfyPage.assetPath(value)
|
||||
result[key] = readFileSync(filePath, 'utf-8')
|
||||
} else {
|
||||
result[key] = this.convertLeafToContent(value)
|
||||
@@ -60,7 +59,7 @@ export class WorkflowHelper {
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.comfyPage.workflowUploadInput.setInputFiles(
|
||||
assetPath(`${workflowName}.json`)
|
||||
this.comfyPage.assetPath(`${workflowName}.json`)
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
@@ -147,16 +146,6 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
|
||||
?.isBusy,
|
||||
undefined,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
|
||||
async getExportedWorkflow(options?: {
|
||||
api?: false
|
||||
|
||||
@@ -20,7 +20,6 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
closeMinimapButton: 'close-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
@@ -98,10 +97,6 @@ export const TestIds = {
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
@@ -130,5 +125,4 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ExpectMatcherState, Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeReference } from './litegraphUtils'
|
||||
|
||||
function makeMatcher<T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
await expect(async () => {
|
||||
const value = await getValue(node)
|
||||
const assertion = this.isNot
|
||||
? expect(value, 'Node is ' + type).not
|
||||
: expect(value, 'Node is not ' + type)
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
await expect
|
||||
.poll(
|
||||
() => locator.evaluate((el) => el === document.activeElement),
|
||||
options
|
||||
)
|
||||
.toBe(!this.isNot)
|
||||
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -47,8 +47,7 @@ export function logMeasurement(
|
||||
export function recordMeasurement(m: PerfMeasurement) {
|
||||
mkdirSync(TEMP_DIR, { recursive: true })
|
||||
const filename = `${m.name}-${Date.now()}.json`
|
||||
const { allFrameDurationsMs: _, ...serializable } = m
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(serializable))
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
|
||||
}
|
||||
|
||||
export function writePerfReport(
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.queuePanel.overlayToggle.click()
|
||||
})
|
||||
|
||||
test('Dialog opens from queue panel history actions menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog shows confirmation message with title, description, and assets note', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'All the finished or failed jobs below will be removed from this Job queue panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Close (X) button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByLabel('Close').click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Confirm clears queue history and closes dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearPromise = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().includes('/api/history') && req.method() === 'POST'
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Clear' }).click()
|
||||
|
||||
const request = await clearPromise
|
||||
expect(request.postDataJSON()).toEqual({ clear: true })
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await expect(clearButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { SignInDialog } from '../../fixtures/components/SignInDialog'
|
||||
|
||||
test.describe('Sign In dialog', { tag: '@ui' }, () => {
|
||||
let dialog: SignInDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
dialog = new SignInDialog(comfyPage.page)
|
||||
await dialog.open()
|
||||
})
|
||||
|
||||
test('Should open and show the sign-in form by default', async () => {
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
await expect(dialog.signInButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle from sign-in to sign-up form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.signUpEmailInput).toBeVisible()
|
||||
await expect(dialog.signUpPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpConfirmPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle back from sign-up to sign-in form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.signInLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should navigate to the API Key form and back', async () => {
|
||||
await dialog.apiKeyButton.click()
|
||||
|
||||
await expect(dialog.apiKeyHeading).toBeVisible()
|
||||
await expect(dialog.apiKeyInput).toBeVisible()
|
||||
|
||||
await dialog.backButton.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display Terms of Service and Privacy Policy links', async () => {
|
||||
await expect(dialog.termsLink).toBeVisible()
|
||||
await expect(dialog.termsLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/terms-of-service'
|
||||
)
|
||||
|
||||
await expect(dialog.privacyLink).toBeVisible()
|
||||
await expect(dialog.privacyLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/privacy'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display the "Or continue with" divider and API key button', async () => {
|
||||
await expect(dialog.dividerText).toBeVisible()
|
||||
await expect(dialog.apiKeyButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show forgot password link on sign-in form', async () => {
|
||||
await expect(dialog.forgotPasswordLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should close dialog via close button', async () => {
|
||||
await dialog.close()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should close dialog via Escape key', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
@@ -78,66 +78,4 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
await expect(minimap).not.toBeVisible()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(toggleButton).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = 3
|
||||
canvas.ds.offset[0] = -800
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
expect(minimapBox).toBeTruthy()
|
||||
expect(viewportBox).toBeTruthy()
|
||||
expect(viewportBox!.width).toBeGreaterThan(0)
|
||||
expect(viewportBox!.height).toBeGreaterThan(0)
|
||||
|
||||
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
|
||||
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
|
||||
minimapBox!.y
|
||||
)
|
||||
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
|
||||
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,34 +0,0 @@
|
||||
# Properties Panel E2E Tests
|
||||
|
||||
Tests for the right-side properties panel (`RightSidePanel.vue`).
|
||||
|
||||
## Structure
|
||||
|
||||
| File | Coverage |
|
||||
| --------------------------------- | ----------------------------------------------------------- |
|
||||
| `openClose.spec.ts` | Panel open/close via actionbar and close button |
|
||||
| `workflowOverview.spec.ts` | No-selection state: tabs, nodes list, global settings |
|
||||
| `nodeSelection.spec.ts` | Single/multi-node selection, selection changes, tab labels |
|
||||
| `titleEditing.spec.ts` | Node title editing via pencil icon |
|
||||
| `searchFiltering.spec.ts` | Widget search and clear |
|
||||
| `nodeSettings.spec.ts` | Settings tab: node state, color, pinned (requires VueNodes) |
|
||||
| `infoTab.spec.ts` | Node help content |
|
||||
| `errorsTab.spec.ts` | Errors tab visibility |
|
||||
| `propertiesPanelPosition.spec.ts` | Panel position relative to sidebar |
|
||||
|
||||
## Shared Helper
|
||||
|
||||
`PropertiesPanelHelper.ts` — Encapsulates panel locators and actions. Instantiated in `beforeEach`:
|
||||
|
||||
```typescript
|
||||
let panel: PropertiesPanelHelper
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Tests requiring VueNodes rendering enable it in `beforeEach` via `comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)` and call `comfyPage.vueNodes.waitForNodes()`.
|
||||
- Verify node state changes via user-facing indicators (text labels like "Bypassed"/"Muted", pin indicator test IDs) rather than internal properties.
|
||||
- Color changes are verified via `page.evaluate` accessing node properties, per the guidance in `docs/guidance/playwright.md`.
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
export class PropertiesPanelHelper {
|
||||
readonly root: Locator
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.closeButton = this.root.locator('button[aria-pressed]')
|
||||
}
|
||||
|
||||
get tabs(): Locator {
|
||||
return this.root.locator('nav button')
|
||||
}
|
||||
|
||||
getTab(label: string): Locator {
|
||||
return this.root.locator('nav button', { hasText: label })
|
||||
}
|
||||
|
||||
get titleEditIcon(): Locator {
|
||||
return this.panelTitle.locator('i[class*="lucide--pencil"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.root.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
|
||||
return this.root.locator('button', { hasText: state })
|
||||
}
|
||||
|
||||
getColorSwatch(colorName: string): Locator {
|
||||
return this.root.locator(`[data-testid="${colorName}"]`)
|
||||
}
|
||||
|
||||
get pinnedSwitch(): Locator {
|
||||
return this.root.locator('[data-p-checked]').first()
|
||||
}
|
||||
|
||||
get subgraphEditButton(): Locator {
|
||||
return this.root.locator('button:has(i[class*="lucide--settings-2"])')
|
||||
}
|
||||
|
||||
get contentArea(): Locator {
|
||||
return this.root.locator('.scrollbar-thin')
|
||||
}
|
||||
|
||||
get errorsTabIcon(): Locator {
|
||||
return this.root.locator('nav i[class*="lucide--octagon-alert"]')
|
||||
}
|
||||
|
||||
get viewAllSettingsButton(): Locator {
|
||||
return this.root.getByRole('button', { name: /view all settings/i })
|
||||
}
|
||||
|
||||
get collapseToggleButton(): Locator {
|
||||
return this.root.locator(
|
||||
'button:has(i[class*="lucide--chevrons-down-up"]), button:has(i[class*="lucide--chevrons-up-down"])'
|
||||
)
|
||||
}
|
||||
|
||||
async open(actionbar: Locator): Promise<void> {
|
||||
if (!(await this.root.isVisible())) {
|
||||
await actionbar.click()
|
||||
await expect(this.root).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (await this.root.isVisible()) {
|
||||
await this.closeButton.click()
|
||||
await expect(this.root).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async switchToTab(label: string): Promise<void> {
|
||||
await this.getTab(label).click()
|
||||
}
|
||||
|
||||
async editTitle(newTitle: string): Promise<void> {
|
||||
await this.titleEditIcon.click()
|
||||
await this.titleInput.fill(newTitle)
|
||||
await this.titleInput.press('Enter')
|
||||
}
|
||||
|
||||
async searchWidgets(query: string): Promise<void> {
|
||||
await this.searchBox.fill(query)
|
||||
}
|
||||
|
||||
async clearSearch(): Promise<void> {
|
||||
await this.searchBox.fill('')
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Errors tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(panel.errorsTabIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Errors tab when errors are disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.errorsTabIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Info tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Info')
|
||||
})
|
||||
|
||||
test('should show node help content', async () => {
|
||||
await expect(panel.contentArea).toBeVisible()
|
||||
await expect(
|
||||
panel.contentArea.getByRole('heading', { name: 'Inputs' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node selection', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
})
|
||||
|
||||
test.describe('Single node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show node title in panel header', async () => {
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should show Parameters, Info, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Info')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Nodes tab for single node', async () => {
|
||||
await expect(panel.getTab('Nodes')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display node widgets in Parameters tab', async () => {
|
||||
await expect(panel.contentArea.getByText('seed')).toBeVisible()
|
||||
await expect(panel.contentArea.getByText('steps')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-node', () => {
|
||||
test('should show item count in title', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.panelTitle).toContainText('3 items selected')
|
||||
})
|
||||
|
||||
test('should list all selected nodes in Parameters tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
panel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should not show Info tab for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection changes', () => {
|
||||
test('should update from no selection to node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should update from node selection back to no selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should update between different single node selections', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nodeOps.selectNodes(['Empty Latent Image'])
|
||||
await expect(panel.panelTitle).toContainText('Empty Latent Image')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tab labels', () => {
|
||||
test('should show "Parameters" tab for single node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Nodes" tab label for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node settings', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Settings')
|
||||
})
|
||||
|
||||
test.describe('Node state', () => {
|
||||
test('should show Normal, Bypass, and Mute state buttons', async () => {
|
||||
await expect(panel.getNodeStateButton('Normal')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Bypass')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Mute')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Bypass mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Mute mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Mute').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Muted')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should restore node to Normal mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
|
||||
await panel.getNodeStateButton('Normal').click()
|
||||
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
|
||||
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node color', () => {
|
||||
test('should display color swatches', async () => {
|
||||
await expect(panel.getColorSwatch('noColor')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('red')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('blue')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should apply color to node', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('should remove color with noColor swatch', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await panel.getColorSwatch('noColor').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color
|
||||
})
|
||||
)
|
||||
.toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pinned state', () => {
|
||||
test('should display pinned toggle', async () => {
|
||||
await expect(panel.pinnedSwitch).toBeVisible()
|
||||
})
|
||||
|
||||
test('should toggle pinned state', async ({ comfyPage }) => {
|
||||
await panel.pinnedSwitch.click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should unpin previously pinned node', async ({ comfyPage }) => {
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(
|
||||
nodeLocator.getByTestId('node-pin-indicator')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Open and close', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should open via actionbar toggle button', async ({ comfyPage }) => {
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via panel close button', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.closeButton.click()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via close button after opening', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.close()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
36
browser_tests/tests/propertiesPanel/propertiesPanel.spec.ts
Normal file
36
browser_tests/tests/propertiesPanel/propertiesPanel.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Properties panel', () => {
|
||||
test('opens and updates title based on selection', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
|
||||
await propertiesPanel.searchBox.fill('seed')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(0)
|
||||
|
||||
await propertiesPanel.searchBox.fill('')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Search filtering', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
})
|
||||
|
||||
test('should filter nodes by search query', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('should restore all nodes when search is cleared', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await panel.clearSearch()
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
test('should show empty state for no matches', async () => {
|
||||
await panel.searchWidgets('nonexistent_widget_xyz')
|
||||
await expect(
|
||||
panel.contentArea.getByText(/no .* match|no results|no items/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Title editing', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show pencil icon for editable title', async () => {
|
||||
await expect(panel.titleEditIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should enter edit mode on pencil click', async () => {
|
||||
await panel.titleEditIcon.click()
|
||||
await expect(panel.titleInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('should update node title on edit', async () => {
|
||||
const newTitle = 'My Custom Sampler'
|
||||
await panel.editTitle(newTitle)
|
||||
await expect(panel.panelTitle).toContainText(newTitle)
|
||||
})
|
||||
|
||||
test('should not show pencil icon for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show pencil icon when nothing is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Workflow Overview', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Workflow Overview" title when nothing is selected', async () => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should show Parameters, Nodes, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Info tab when nothing is selected', async () => {
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Nodes tab and list all workflow nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await panel.switchToTab('Nodes')
|
||||
const nodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCount).toBeGreaterThan(0)
|
||||
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should filter nodes by search in Nodes tab', async () => {
|
||||
await panel.switchToTab('Nodes')
|
||||
await panel.searchWidgets('KSampler')
|
||||
await expect(panel.contentArea.getByText('KSampler').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Settings tab and show global settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "View all settings" button', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Nodes section with toggles', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(
|
||||
panel.contentArea.getByRole('button', { name: 'NODES' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Canvas section with grid settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Canvas')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Connection Links section', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Connection Links')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { components } from '@comfyorg/registry-types'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
function createMockRelease(overrides?: Partial<ReleaseNote>): ReleaseNote {
|
||||
return {
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Release Notifications', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -37,10 +22,15 @@ test.describe('Release Notifications', () => {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
createMockRelease({
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content:
|
||||
'## New Features\n\n- Added awesome feature\n- Fixed important bug'
|
||||
})
|
||||
'## New Features\n\n- Added awesome feature\n- Fixed important bug',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
@@ -167,7 +157,16 @@ test.describe('Release Notifications', () => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([createMockRelease({ attention: 'high' })])
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'high',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
@@ -251,7 +250,16 @@ test.describe('Release Notifications', () => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([createMockRelease()])
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
@@ -295,10 +303,14 @@ test.describe('Release Notifications', () => {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
createMockRelease({
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'low',
|
||||
content: '## Bug Fixes\n\n- Fixed minor issue'
|
||||
})
|
||||
content: '## Bug Fixes\n\n- Fixed minor issue',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -1,72 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '../../fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
]
|
||||
|
||||
const SAMPLE_IMPORTED_FILES = [
|
||||
'reference_photo.png',
|
||||
'background.jpg',
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.describe('Assets sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
@@ -76,594 +12,19 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
test('Shows empty-state copy for generated and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
await tab.importedTab.click()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('Can switch between Generated and Imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.waitForAssets()
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Displays imported files when switching to Imported tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filtering assets by search query reduces displayed count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect(async () => {
|
||||
const filteredCount = await tab.assetCards.count()
|
||||
expect(filteredCount).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(async () => {
|
||||
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Selection shows footer with count and actions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Context menu contains Download action for output asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Download')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Inspect action for image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Delete action for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Delete')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Copy job ID for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains workflow actions for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await expect(
|
||||
tab.contextMenuItem('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bulk context menu shows when multiple assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Footer shows delete button when output assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select two assets
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
const text = await tab.selectionCountButton.textContent()
|
||||
expect(text).toMatch(/Assets Selected: \d+/)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Initially loads a batch of assets with has_more pagination', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Create a large set of jobs to trigger pagination
|
||||
const manyJobs = createMockJobs(30)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Should load at least the first batch
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkflowTemplates } from '../../src/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
@@ -245,7 +244,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await comfyPage.page.route(
|
||||
'**/templates/index.json',
|
||||
async (route, _) => {
|
||||
const response: WorkflowTemplates[] = [
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
|
||||
const header = comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.locator('.lg-node-header')
|
||||
await header.click()
|
||||
await header.click({ button: 'right' })
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
async function openMultiNodeContextMenu(
|
||||
comfyPage: ComfyPage,
|
||||
titles: string[]
|
||||
) {
|
||||
// deselectAll via evaluate — clearSelection() clicks at a fixed position
|
||||
// which can hit nodes or the toolbar overlay
|
||||
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
for (const title of titles) {
|
||||
const header = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator('.lg-node-header')
|
||||
await header.click({ modifiers: ['ControlOrMeta'] })
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstHeader = comfyPage.vueNodes
|
||||
.getNodeByTitle(titles[0])
|
||||
.locator('.lg-node-header')
|
||||
const box = await firstHeader.boundingBox()
|
||||
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width / 2,
|
||||
box.y + box.height / 2,
|
||||
{ button: 'right' }
|
||||
)
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
|
||||
return comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: nodeTitle })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
}
|
||||
|
||||
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
|
||||
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
return refs[0]
|
||||
}
|
||||
|
||||
test.describe('Vue Node Context Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.describe('Single Node Actions', () => {
|
||||
test('should rename node via context menu', async ({ comfyPage }) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
const titleInput = comfyPage.page.locator(
|
||||
'.node-title-editor input[type="text"]'
|
||||
)
|
||||
await titleInput.waitFor({ state: 'visible' })
|
||||
await titleInput.fill('My Renamed Sampler')
|
||||
await titleInput.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const renamedNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openContextMenu(comfyPage, 'Load Checkpoint')
|
||||
await clickExactMenuItem(comfyPage, 'Copy')
|
||||
|
||||
// Internal clipboard paste (menu Copy uses canvas clipboard, not OS)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + 1
|
||||
)
|
||||
})
|
||||
|
||||
test('should duplicate node via context menu', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openContextMenu(comfyPage, 'Load Checkpoint')
|
||||
await clickExactMenuItem(comfyPage, 'Duplicate')
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + 1
|
||||
)
|
||||
})
|
||||
|
||||
test('should pin and unpin node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
|
||||
// Pin via context menu
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
const pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(true)
|
||||
|
||||
// Verify drag blocked
|
||||
const header = comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.locator('.lg-node-header')
|
||||
const posBeforeDrag = await header.boundingBox()
|
||||
if (!posBeforeDrag) throw new Error('Header not found')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 },
|
||||
{ x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 }
|
||||
)
|
||||
const posAfterDrag = await header.boundingBox()
|
||||
expect(posAfterDrag).toEqual(posBeforeDrag)
|
||||
|
||||
// Unpin via context menu
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Unpin')
|
||||
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(false)
|
||||
})
|
||||
|
||||
test('should bypass node and remove bypass via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('should minimize and expand node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(fixture.body).toBeVisible()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
||||
await expect(fixture.body).not.toBeVisible()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Expand Node')
|
||||
await expect(fixture.body).toBeVisible()
|
||||
})
|
||||
|
||||
test('should convert node to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Image Node Actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('should copy image to clipboard via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
await clickExactMenuItem(comfyPage, 'Copy Image')
|
||||
|
||||
// Verify the clipboard contains an image
|
||||
const hasImage = await comfyPage.page.evaluate(async () => {
|
||||
const items = await navigator.clipboard.read()
|
||||
return items.some((item) =>
|
||||
item.types.some((t) => t.startsWith('image/'))
|
||||
)
|
||||
})
|
||||
expect(hasImage).toBe(true)
|
||||
})
|
||||
|
||||
test('should paste image to LoadImage node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Capture the original image src from the node's preview
|
||||
const imagePreview = comfyPage.page.locator('.image-preview img')
|
||||
const originalSrc = await imagePreview.getAttribute('src')
|
||||
|
||||
// Write a test image into the browser clipboard
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const resp = await fetch('/api/view?filename=example.png&type=input')
|
||||
const blob = await resp.blob()
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
])
|
||||
})
|
||||
|
||||
// Right-click and select Paste Image
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
await clickExactMenuItem(comfyPage, 'Paste Image')
|
||||
|
||||
// Verify the image preview src changed
|
||||
await expect(imagePreview).not.toHaveAttribute('src', originalSrc!)
|
||||
})
|
||||
|
||||
test('should open image in new tab via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
|
||||
const popupPromise = comfyPage.page.waitForEvent('popup')
|
||||
await clickExactMenuItem(comfyPage, 'Open Image')
|
||||
const popup = await popupPromise
|
||||
|
||||
expect(popup.url()).toContain('/api/view')
|
||||
expect(popup.url()).toContain('filename=')
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('should download image via Save Image context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await clickExactMenuItem(comfyPage, 'Save Image')
|
||||
const download = await downloadPromise
|
||||
|
||||
expect(download.suggestedFilename()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Actions', () => {
|
||||
test('should convert to subgraph and unpack back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert KSampler to subgraph
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Unpack the subgraph
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Unpack Subgraph')
|
||||
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should open properties panel via Edit Subgraph Widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert to subgraph first
|
||||
await openContextMenu(comfyPage, 'Empty Latent Image')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Right-click subgraph and edit widgets
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets')
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should add subgraph to library and find in node library', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert to subgraph first
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Add to library
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Add Subgraph to Library')
|
||||
|
||||
// Fill the blueprint name
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
|
||||
|
||||
// Open node library sidebar and search for the blueprint
|
||||
await comfyPage.page.getByRole('button', { name: 'Node Library' }).click()
|
||||
await comfyPage.nextFrame()
|
||||
const searchBox = comfyPage.page.getByRole('combobox', {
|
||||
name: 'Search'
|
||||
})
|
||||
await searchBox.waitFor({ state: 'visible' })
|
||||
await searchBox.fill('TestBlueprint')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-Node Actions', () => {
|
||||
const nodeTitles = ['Load Checkpoint', 'KSampler']
|
||||
|
||||
test('should batch rename selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('MyNode')
|
||||
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should copy and paste selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Copy')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + nodeTitles.length
|
||||
)
|
||||
})
|
||||
|
||||
test('should duplicate selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Duplicate')
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + nodeTitles.length
|
||||
)
|
||||
})
|
||||
|
||||
test('should pin and unpin selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
}
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Unpin')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should bypass and remove bypass on selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
|
||||
}
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('should minimize and expand selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fixture1 =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await expect(fixture1.body).toBeVisible()
|
||||
await expect(fixture2.body).toBeVisible()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
||||
|
||||
await expect(fixture1.body).not.toBeVisible()
|
||||
await expect(fixture2.body).not.toBeVisible()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Expand Node')
|
||||
|
||||
await expect(fixture1.body).toBeVisible()
|
||||
await expect(fixture2.body).toBeVisible()
|
||||
})
|
||||
|
||||
test('should frame selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Frame Nodes')
|
||||
|
||||
const newGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
expect(newGroupCount).toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('should convert to group node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
|
||||
|
||||
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'workflow>TestGroupNode'
|
||||
)
|
||||
expect(groupNodes.length).toBe(1)
|
||||
})
|
||||
|
||||
test('should convert selected nodes to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount - nodeTitles.length + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,532 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow Persistence', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Rapid tab switching does not desync workflow and graph state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — desynced workflow/graph state during loading'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-A')
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-B')
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await tab.switchToWorkflow('rapid-B')
|
||||
}
|
||||
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountA)
|
||||
})
|
||||
|
||||
test('Node outputs are preserved when switching workflow tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #9380 — ChangeTracker.store() did not save nodeOutputs, losing preview images on tab switch'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
|
||||
|
||||
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
expect(firstNode).toBeTruthy()
|
||||
const nodeId = firstNode!.id
|
||||
|
||||
// Simulate node outputs as if execution completed
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
const outputStore = window.app!.nodeOutputs
|
||||
if (outputStore) {
|
||||
outputStore[id] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}, String(nodeId))
|
||||
|
||||
// Trigger changeTracker to capture current state including outputs
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsBefore = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsBefore).toBeTruthy()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('outputs-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsAfter = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsAfter).toBeTruthy()
|
||||
expect(outputsAfter?.images).toBeDefined()
|
||||
})
|
||||
|
||||
test('Loading a new workflow cleanly replaces the previous graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commit 44bb6f13 — canvas graph not reset before workflow load'
|
||||
})
|
||||
|
||||
const defaultNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(defaultNodeCount).toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(1)
|
||||
|
||||
const nodes = await comfyPage.nodeOps.getNodes()
|
||||
expect(nodes[0].type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('Widget values on nodes are preserved across workflow tab switches', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #7648 — component widget state lost on graph change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('widget-state-test')
|
||||
|
||||
// Read widget values via page.evaluate — these are internal LiteGraph
|
||||
// state not exposed through DOM
|
||||
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('widget-state-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
|
||||
})
|
||||
|
||||
test('API format workflow with missing node types partially loads', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9694 — loadApiJson early-returned on missing node types'
|
||||
})
|
||||
|
||||
const fixturePath = comfyPage.assetPath(
|
||||
'nodes/api_workflow_with_missing_nodes.json'
|
||||
)
|
||||
const apiWorkflow = JSON.parse(readFileSync(fixturePath, 'utf-8'))
|
||||
|
||||
await comfyPage.page.evaluate(async (workflow) => {
|
||||
await window.app!.loadApiJson(workflow, 'test-api-workflow.json')
|
||||
}, apiWorkflow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Known nodes (KSampler, EmptyLatentImage) should load; unknown node skipped
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
|
||||
const nodeTypes = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.nodes.map((n: { type: string }) => n.type)
|
||||
})
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
expect(nodeTypes).toContain('EmptyLatentImage')
|
||||
expect(nodeTypes).not.toContain('NonExistentCustomNode_XYZ_12345')
|
||||
})
|
||||
|
||||
test('Canvas has auxclick handler to prevent middle-click paste', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8259 — middle-click paste duplicates entire workflow on Linux'
|
||||
})
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
button: 'middle',
|
||||
position: { x: 100, y: 100 }
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountAfter).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain transient blob: URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8715 — transient image URLs leaked into workflow serialization'
|
||||
})
|
||||
|
||||
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
for (const node of exportedWorkflow.nodes) {
|
||||
if (node.widgets_values && Array.isArray(node.widgets_values)) {
|
||||
for (const value of node.widgets_values) {
|
||||
if (typeof value === 'string') {
|
||||
expect(value).not.toMatch(/^blob:/)
|
||||
expect(value).not.toMatch(/^https?:\/\/.*\/api\/view/)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Changing locale does not break workflow operations', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #8963 — template workflows not reloaded on locale change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.menu.topbar.saveWorkflow('locale-test')
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
|
||||
await expect.poll(() => tab.getActiveWorkflowName()).toBe('locale-test')
|
||||
})
|
||||
|
||||
test('Node links survive save/load/switch cycles', async ({ comfyPage }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — node links must survive serialization roundtrips'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
// Link count requires internal graph state — not exposed via DOM
|
||||
const linkCountBefore = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountBefore).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('links-test')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('links-test')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const linkCountAfter = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Closing an inactive tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(nodeCountA + 1)
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive B tab via middle-click — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
|
||||
button: 'middle'
|
||||
})
|
||||
|
||||
// Click "Save" in the dirty close dialog
|
||||
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have the extra Note node we added, not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Closing an inactive unsaved tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(1)
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
// Switch to A via topbar tab (making unsaved B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive unsaved B tab — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar
|
||||
.getWorkflowTab('Unsaved Workflow')
|
||||
.click({ button: 'middle' })
|
||||
|
||||
// Click "Save" in the dirty close dialog (scoped to dialog)
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
|
||||
// Fill in the filename dialog
|
||||
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
|
||||
await saveDialog.waitFor({ state: 'visible' })
|
||||
await saveDialog.fill(nameB)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have 1 node (the Note), not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commits 91f197d9d + a1b7e57bc — splitter panel size drift on reload'
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'Comfy.Splitter.MainSplitter',
|
||||
JSON.stringify([30, 70])
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const storedSizes = await comfyPage.page.evaluate(() => {
|
||||
const raw = localStorage.getItem('Comfy.Splitter.MainSplitter')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
|
||||
expect(storedSizes).toBeTruthy()
|
||||
expect(Array.isArray(storedSizes)).toBe(true)
|
||||
for (const size of storedSizes as number[]) {
|
||||
expect(typeof size).toBe('number')
|
||||
expect(size).toBeGreaterThanOrEqual(0)
|
||||
expect(size).not.toBeNaN()
|
||||
}
|
||||
const total = (storedSizes as number[]).reduce(
|
||||
(a: number, b: number) => a + b,
|
||||
0
|
||||
)
|
||||
expect(total).toBeGreaterThan(90)
|
||||
expect(total).toBeLessThanOrEqual(101)
|
||||
})
|
||||
})
|
||||
@@ -363,7 +363,7 @@ Test your feature flags with different combinations:
|
||||
### Example Test
|
||||
|
||||
```typescript
|
||||
// Example from a colocated unit test
|
||||
// In tests-ui/tests/api.featureFlags.test.ts
|
||||
it('should handle preview metadata based on feature flag', () => {
|
||||
// Mock server supports feature
|
||||
api.serverFeatureFlags = { supports_preview_metadata: true }
|
||||
|
||||
683
docs/architecture/WALKTHROUGH.txt
Normal file
683
docs/architecture/WALKTHROUGH.txt
Normal file
@@ -0,0 +1,683 @@
|
||||
===============================================================================
|
||||
____ _ _ ____
|
||||
/ ___|___ __| | ___| |__ __ _ ___ ___ / ___|__ ___ _____ _ __ _ __ ___
|
||||
| | / _ \ / _` |/ _ \ '_ \ / _` / __|/ _ \ | | / _` \ \ / / _ \ '__| '_ \/ __|
|
||||
| |__| (_) | (_| | __/ |_) | (_| \__ \ __/ | |__| (_| |\ V / __/ | | | | \__ \
|
||||
\____\___/ \__,_|\___|_.__/ \__,_|___/\___| \____\__,_| \_/ \___|_| |_| |_|___/
|
||||
|
||||
ComfyUI Frontend Architecture Adventure - Complete Walkthrough
|
||||
===============================================================================
|
||||
|
||||
Platform: Web Browser (any modern browser)
|
||||
Version: 1.0
|
||||
Author: An Architect Who Has Seen Things
|
||||
Last Updated: 2026-03-24
|
||||
Spoilers: YES. This guide contains ALL solutions and ALL endings.
|
||||
|
||||
===============================================================================
|
||||
TABLE OF CONTENTS
|
||||
===============================================================================
|
||||
|
||||
I. Introduction & Controls
|
||||
II. Game Mechanics
|
||||
III. Room Guide & Map
|
||||
IV. Challenge Solutions (SPOILERS)
|
||||
V. Optimal Route - "The ECS Enlightenment" Speedrun
|
||||
VI. All Four Endings
|
||||
VII. Achievements
|
||||
VIII. Artifacts Checklist
|
||||
IX. Pro Tips & Secrets
|
||||
|
||||
===============================================================================
|
||||
I. INTRODUCTION & CONTROLS
|
||||
===============================================================================
|
||||
|
||||
Codebase Caverns is an interactive choose-your-own-adventure game that
|
||||
teaches you the architecture of the ComfyUI frontend codebase. You explore
|
||||
10 rooms representing different architectural layers, face 9 real engineering
|
||||
challenges, collect artifacts, and reach one of 4 endings based on your
|
||||
decisions.
|
||||
|
||||
Every challenge in this game is based on REAL architectural problems
|
||||
documented in the ComfyUI frontend repo. The "correct" answers match the
|
||||
actual migration strategy being used in production.
|
||||
|
||||
CONTROLS:
|
||||
=========
|
||||
1, 2, 3 Navigate between rooms (press the number key)
|
||||
A, B, C Choose a challenge option (press the letter key)
|
||||
M Toggle the map overlay
|
||||
Escape Close the map / close ending preview
|
||||
|
||||
BUTTONS:
|
||||
========
|
||||
Map [M] Opens the room map overlay
|
||||
Restart Resets the current run (keeps achievements)
|
||||
Play Again After an ending, starts a new run
|
||||
|
||||
Your progress auto-saves to localStorage. Close the tab and come back
|
||||
later - you'll pick up right where you left off.
|
||||
|
||||
===============================================================================
|
||||
II. GAME MECHANICS
|
||||
===============================================================================
|
||||
|
||||
STATS
|
||||
=====
|
||||
You have four stats tracked in the HUD at the top:
|
||||
|
||||
Debt [||||||||..] 50 Technical debt. LOWER is better.
|
||||
Quality [|||.......] 30 Code quality. HIGHER is better.
|
||||
Morale [||||||....] 60 Team morale. HIGHER is better.
|
||||
ECS [.........] 0/5 Migration progress. 5 is max.
|
||||
|
||||
Each challenge choice modifies these stats. Your final stats determine
|
||||
which of the 4 endings you get.
|
||||
|
||||
CHALLENGES
|
||||
==========
|
||||
9 of the 10 rooms contain a one-time challenge - an architectural dilemma
|
||||
with 2-3 options. Each option has a rating:
|
||||
|
||||
[GOOD] Best practice. Matches the real migration strategy.
|
||||
Usually: Debt down, Quality up, +1 ECS progress.
|
||||
|
||||
[OK] Pragmatic but imperfect. Gets the job done.
|
||||
Mixed stat effects.
|
||||
|
||||
[BAD] Tempting but harmful. Short-term gain, long-term pain.
|
||||
Usually: Debt up or Morale down.
|
||||
|
||||
After choosing, you see your result, the recommended answer, and a link
|
||||
to the real architecture documentation that explains why.
|
||||
|
||||
ARTIFACTS
|
||||
=========
|
||||
Rooms contain collectible artifacts - key files and concepts from the
|
||||
codebase. These are auto-collected when you enter a room. They appear
|
||||
as icons in your Inventory sidebar.
|
||||
|
||||
ENDINGS
|
||||
=======
|
||||
After resolving all 8 challenges, you get one of 4 endings based on
|
||||
your accumulated stats. See Section VI for details.
|
||||
|
||||
ACHIEVEMENTS
|
||||
============
|
||||
Each ending you reach is permanently saved as an achievement badge.
|
||||
Achievements persist across runs - even after restarting. Click an
|
||||
unlocked badge to review that ending's screen.
|
||||
|
||||
===============================================================================
|
||||
III. ROOM GUIDE & MAP
|
||||
===============================================================================
|
||||
|
||||
+-------------------+
|
||||
| ENTRY POINT |
|
||||
| (src/main.ts) |
|
||||
+-+--------+------+-+
|
||||
| | |
|
||||
+----------+ | +-----------+
|
||||
| | |
|
||||
+---v----------+ +-----v--------+ +------v---------+
|
||||
| COMPONENT | | STORE | | SERVICE |
|
||||
| GALLERY | | VAULTS | | CORRIDORS |
|
||||
| [Challenge] | | [Challenge] | | [Challenge] |
|
||||
+--+------+----+ +--+------+----+ +--------+-------+
|
||||
| | | | |
|
||||
| | +----v---+ | +------v-------+
|
||||
| | | ECS | | | COMPOSABLES |
|
||||
| | | CHAMB. | | | WORKSHOP |
|
||||
| | | [Chal] | | | [Challenge] |
|
||||
| | +---+----+ | +--------------+
|
||||
| | | +----v------+
|
||||
| | +----v--+--+ |
|
||||
| | |SUBGRAPH| RENDERER |
|
||||
| | | DEPTHS | OVERLOOK |
|
||||
| | | [Chal] | [Chal] |
|
||||
| | +--------+----------+
|
||||
| |
|
||||
+--v------v----+
|
||||
| LITEGRAPH |
|
||||
| ENGINE |
|
||||
| [Challenge] |
|
||||
+------+-------+
|
||||
|
|
||||
+------v-------+
|
||||
| COMMAND |
|
||||
| FORGE |
|
||||
| [Challenge] |
|
||||
+--------------+
|
||||
|
||||
ROOM DETAILS:
|
||||
=============
|
||||
|
||||
1. THE ENTRY POINT [src/main.ts]
|
||||
No challenge. No artifacts. Starting room.
|
||||
Exits: Components (1), Stores (2), Services (3)
|
||||
|
||||
2. THE COMPONENT GALLERY [Presentation]
|
||||
Challenge: The Circular Dependency
|
||||
Artifacts: GraphView.vue
|
||||
Exits: Litegraph (1), Command Forge (2), Entry (3)
|
||||
|
||||
3. THE STORE VAULTS [State]
|
||||
Challenge: The Scattered Mutations
|
||||
Artifacts: widgetValueStore.ts, layoutStore.ts
|
||||
Exits: ECS (1), Renderer (2), Entry (3)
|
||||
|
||||
4. THE SERVICE CORRIDORS [Services]
|
||||
Challenge: The Migration Question
|
||||
Artifacts: litegraphService.ts, Extension Migration Guide
|
||||
Exits: Composables (1), Entry (2)
|
||||
|
||||
5. THE LITEGRAPH ENGINE ROOM [Graph Engine]
|
||||
Challenge: The God Object Dilemma
|
||||
Artifacts: LGraphCanvas.ts, LGraphNode.ts
|
||||
Exits: ECS (1), Components (2), Entry (3)
|
||||
|
||||
6. THE ECS ARCHITECT'S CHAMBER [ECS]
|
||||
Challenge: The ID Crossroads
|
||||
Artifacts: World Registry, Branded Entity IDs
|
||||
Exits: Subgraph Depths (1), Renderer (2), Entry (3)
|
||||
|
||||
7. THE SUBGRAPH DEPTHS [Graph Boundaries]
|
||||
Challenge: The Widget Promotion Decision
|
||||
Artifacts: SubgraphStructure, Typed Interface Contracts
|
||||
Exits: ECS (1), Litegraph (2), Entry (3)
|
||||
|
||||
8. THE RENDERER OVERLOOK [Renderer]
|
||||
Challenge: The Render-Time Mutation
|
||||
Artifacts: QuadTree Spatial Index, Y.js CRDT Layout
|
||||
Exits: ECS (1), Entry (2)
|
||||
|
||||
9. THE COMPOSABLES WORKSHOP [Composables]
|
||||
Challenge: The Collaboration Protocol
|
||||
Artifacts: useCoreCommands.ts
|
||||
Exits: Stores (1), Entry (2)
|
||||
|
||||
10. THE COMMAND FORGE [Commands & Intent]
|
||||
Challenge: The Mutation Gateway
|
||||
Artifacts: CommandExecutor, Command Interface
|
||||
Exits: Components (1), Stores (2), Entry (3)
|
||||
|
||||
===============================================================================
|
||||
IV. CHALLENGE SOLUTIONS (SPOILERS)
|
||||
===============================================================================
|
||||
|
||||
*** WARNING: FULL SOLUTIONS BELOW ***
|
||||
*** SCROLL PAST SECTION VI IF YOU WANT TO PLAY BLIND ***
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 1: The Circular Dependency | Room: Components |
|
||||
|------------------------------------------------------------------ |
|
||||
| Subgraph extends LGraph, but LGraph creates Subgraph instances. |
|
||||
| Circular import forces order-dependent barrel exports. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Composition over inheritance [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 |
|
||||
| A subgraph IS a graph - just a node with SubgraphStructure. |
|
||||
| Under graph unification, no class inheritance at all. |
|
||||
| |
|
||||
| B. Barrel file reordering [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| Band-aid. The coupling remains and will break again. |
|
||||
| |
|
||||
| C. Factory injection [OK] |
|
||||
| Debt -5, Quality +10 |
|
||||
| Pragmatic fix but classes stay coupled at runtime. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 2: The Scattered Mutations | Room: Stores |
|
||||
|------------------------------------------------------------------ |
|
||||
| graph._version++ appears in 19 locations across 7 files. |
|
||||
| One missed site = silent data loss. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Centralize into graph.incrementVersion() [GOOD] <<< |
|
||||
| Debt -15, Quality +15, ECS +1 |
|
||||
| This is Phase 0a of the real migration plan. |
|
||||
| 19 sites -> 1 method. Auditable change tracking. |
|
||||
| |
|
||||
| B. Add a JavaScript Proxy [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| Catches mutations but adds opaque runtime overhead. |
|
||||
| |
|
||||
| C. Leave it as-is [BAD] |
|
||||
| Debt +10, Morale +5 |
|
||||
| "It works, don't touch it" - until it doesn't. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 3: The Migration Question | Room: Services |
|
||||
|------------------------------------------------------------------ |
|
||||
| Legacy litegraph works. How to migrate to ECS without breaking |
|
||||
| production for thousands of users? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. 5-phase incremental plan [GOOD] <<< |
|
||||
| Quality +15, Morale +10, ECS +1 |
|
||||
| Foundation -> Types -> Bridge -> Systems -> Legacy Removal. |
|
||||
| Each phase independently shippable. This is the real plan. |
|
||||
| |
|
||||
| B. Big bang rewrite [BAD] |
|
||||
| Debt -10, Quality +5, Morale -20 |
|
||||
| Feature freeze + scope creep + burnout = disaster. |
|
||||
| |
|
||||
| C. Strangler fig pattern [OK] |
|
||||
| Quality +10, Morale +5 |
|
||||
| Solid pattern but lacks clear milestones without a plan. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 4: The God Object Dilemma | Room: Litegraph |
|
||||
|------------------------------------------------------------------ |
|
||||
| LGraphCanvas: ~9,100 lines. LGraphNode: ~4,300 lines. |
|
||||
| God objects mixing rendering, serialization, connectivity, etc. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| A. Rewrite from scratch [BAD] |
|
||||
| Debt -20, Quality +5, Morale -25 |
|
||||
| Heroic rewrite stalls at month three. Team burns out. |
|
||||
| |
|
||||
| >>> B. Extract incrementally [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 |
|
||||
| Position -> Connectivity -> Rendering. Small testable PRs. |
|
||||
| This matches the actual migration strategy. |
|
||||
| |
|
||||
| C. Add a facade layer [OK] |
|
||||
| Debt +5, Quality +5, Morale +10 |
|
||||
| Nicer API but complexity lives behind the facade. |
|
||||
| |
|
||||
| NOTE: This is the only challenge where A is NOT the best answer! |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 5: The ID Crossroads | Room: ECS |
|
||||
|------------------------------------------------------------------ |
|
||||
| NodeId is number | string. Nothing prevents passing a LinkId |
|
||||
| where a NodeId is expected. Six entity kinds share one ID space. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Branded types with cast helpers [GOOD] <<< |
|
||||
| Debt -15, Quality +20, ECS +1 |
|
||||
| type NodeEntityId = number & { __brand: 'NodeEntityId' } |
|
||||
| Compile-time safety, zero runtime cost. Phase 1a. |
|
||||
| |
|
||||
| B. String prefixes at runtime [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| "node:42" - parsing overhead everywhere. |
|
||||
| |
|
||||
| C. Keep plain numbers [BAD] |
|
||||
| Debt +15, Quality -5 |
|
||||
| "Just be careful" - someone WILL pass the wrong ID. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 6: The Widget Promotion Decision | Room: Subgraph |
|
||||
|------------------------------------------------------------------ |
|
||||
| A user promotes a widget from inside a subgraph to the parent. |
|
||||
| Today this needs PromotionStore + ViewManager + PromotedWidgetView |
|
||||
| — a parallel state system. Two ECS candidates. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Connections-only: promotion = typed input [GOOD] <<< |
|
||||
| Debt -15, Quality +15, Morale +5, ECS +1 |
|
||||
| Promotion = adding an interface input. Type->widget mapping |
|
||||
| creates the widget automatically. Eliminates PromotionStore, |
|
||||
| ViewManager, and PromotedWidgetView entirely. |
|
||||
| |
|
||||
| B. Simplified component promotion [OK] |
|
||||
| Debt -5, Quality +10, Morale +5 |
|
||||
| WidgetPromotion component on widget entities. Removes |
|
||||
| ViewManager but keeps promotion as a distinct concept. |
|
||||
| Shared subgraph instance ambiguity remains. |
|
||||
| |
|
||||
| C. Keep the current three-layer system [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| The parallel state system persists indefinitely. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 7: The Render-Time Mutation | Room: Renderer |
|
||||
|------------------------------------------------------------------ |
|
||||
| drawNode() calls _setConcreteSlots() and arrange() during the |
|
||||
| render pass. Draw order affects layout. Classic mutation-in- |
|
||||
| render bug. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Separate update and render phases [GOOD] <<< |
|
||||
| Debt -15, Quality +15, ECS +1 |
|
||||
| Input -> Update (layout) -> Render (read-only). |
|
||||
| Matches the ECS system pipeline design. |
|
||||
| |
|
||||
| B. Dirty flags and deferred render [OK] |
|
||||
| Debt -5, Quality +5, Morale +5 |
|
||||
| Reduces symptoms but render pass can still mutate. |
|
||||
| |
|
||||
| NOTE: Only 2 options here. Both are reasonable; A is optimal. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 8: The Collaboration Protocol | Room: Composables |
|
||||
|------------------------------------------------------------------ |
|
||||
| Multiple users want to edit the same workflow simultaneously. |
|
||||
| layoutStore already extracts position data. How to sync? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Y.js CRDTs [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +10 |
|
||||
| Conflict-free replicated data types. Already proven. |
|
||||
| This is what the real layoutStore uses. |
|
||||
| |
|
||||
| B. Polling-based sync [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| Flickering, lag, silent data loss. Support nightmare. |
|
||||
| |
|
||||
| C. Skip collaboration for now [OK] |
|
||||
| Morale +5 |
|
||||
| Pragmatic delay but cloud team won't be happy. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 9: The Mutation Gateway | Room: Command Forge |
|
||||
|------------------------------------------------------------------ |
|
||||
| The World's imperative API (world.setComponent()) vs. the command |
|
||||
| pattern requirement from ADR 0003. How should external callers |
|
||||
| mutate the World? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Commands as intent; systems as handlers; World as store <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 [GOOD] |
|
||||
| Caller -> Command -> System -> World -> Y.js. Commands are |
|
||||
| serializable. ADR 0003 and ADR 0008 are complementary. |
|
||||
| |
|
||||
| B. Make World.setComponent() itself serializable [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| Conflates store with command layer. Batch ops become noisy. |
|
||||
| |
|
||||
| C. Skip commands - let callers mutate directly [BAD] |
|
||||
| Debt +15, Quality -10 |
|
||||
| No undo/redo, no replay, no CRDT sync, no audit trail. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
===============================================================================
|
||||
V. OPTIMAL ROUTE - "THE ECS ENLIGHTENMENT" SPEEDRUN
|
||||
===============================================================================
|
||||
|
||||
This route hits all 8 challenges picking the GOOD answer, collecting
|
||||
all 13 artifacts, visiting all 10 rooms. Order matters for efficiency
|
||||
(fewest key presses).
|
||||
|
||||
Starting stats: Debt 50, Quality 30, Morale 60, ECS 0/5
|
||||
|
||||
ENTRY POINT
|
||||
Press 1 -> Component Gallery
|
||||
|
||||
COMPONENT GALLERY
|
||||
Challenge: The Circular Dependency -> Press A (Composition)
|
||||
[Debt 40, Quality 45, Morale 65, ECS 1/5]
|
||||
Press 2 -> Command Forge
|
||||
|
||||
THE COMMAND FORGE
|
||||
Challenge: The Mutation Gateway -> Press A (Commands as intent)
|
||||
[Debt 30, Quality 60, Morale 70, ECS 2/5]
|
||||
Press 2 -> Store Vaults
|
||||
|
||||
STORE VAULTS
|
||||
Challenge: The Scattered Mutations -> Press A (Centralize)
|
||||
[Debt 15, Quality 75, Morale 70, ECS 3/5]
|
||||
Press 1 -> ECS Chamber
|
||||
|
||||
ECS ARCHITECT'S CHAMBER
|
||||
Challenge: The ID Crossroads -> Press A (Branded types)
|
||||
[Debt 0, Quality 95, Morale 70, ECS 4/5]
|
||||
Press 1 -> Subgraph Depths
|
||||
|
||||
SUBGRAPH DEPTHS
|
||||
Challenge: The Widget Promotion Decision -> Press A (Connections-only)
|
||||
[Debt 0, Quality 100, Morale 75, ECS 5/5]
|
||||
Press 1 -> ECS Chamber
|
||||
Press 2 -> Renderer
|
||||
|
||||
RENDERER OVERLOOK
|
||||
Challenge: The Render-Time Mutation -> Press A (Separate phases)
|
||||
[Debt 0, Quality 100, Morale 75, ECS 5/5]
|
||||
Press 2 -> Entry Point
|
||||
|
||||
ENTRY POINT
|
||||
Press 3 -> Services
|
||||
|
||||
SERVICE CORRIDORS
|
||||
Challenge: The Migration Question -> Press A (5-phase plan)
|
||||
[Debt 0, Quality 100, Morale 85, ECS 5/5]
|
||||
Press 1 -> Composables
|
||||
|
||||
COMPOSABLES WORKSHOP
|
||||
Challenge: The Collaboration Protocol -> Press A (Y.js CRDTs)
|
||||
[Debt 0, Quality 100, Morale 95, ECS 5/5]
|
||||
Press 2 -> Entry Point
|
||||
|
||||
ENTRY POINT
|
||||
Press 1 -> Components
|
||||
Press 1 -> Litegraph
|
||||
|
||||
LITEGRAPH ENGINE ROOM
|
||||
Challenge: The God Object Dilemma -> Press B (Extract incrementally)
|
||||
[Debt 0, Quality 100, Morale 100, ECS 5/5]
|
||||
|
||||
FINAL STATS: Debt 0 | Quality 100 | Morale 100 | ECS 5/5
|
||||
|
||||
*** ENDING: THE ECS ENLIGHTENMENT ***
|
||||
|
||||
Total key presses: 28 (including challenge answers)
|
||||
Rooms visited: 10/10
|
||||
Artifacts: 16/16
|
||||
Challenges: 9/9 correct
|
||||
|
||||
===============================================================================
|
||||
VI. ALL FOUR ENDINGS
|
||||
===============================================================================
|
||||
|
||||
Endings are checked in order. First match wins.
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 1: THE ECS ENLIGHTENMENT [BEST] |
|
||||
| |
|
||||
| Requirements: Debt < 25 AND Quality >= 75 AND Morale >= 60 |
|
||||
| |
|
||||
| "The World registry hums with clean data. Node removal: |
|
||||
| 30 lines instead of 107. Serialization: one system instead |
|
||||
| of six scattered methods. Branded IDs catch bugs at compile |
|
||||
| time. Y.js CRDTs enable real-time collaboration. The team |
|
||||
| ships features faster than ever." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick ALL good answers. Hard to miss if you |
|
||||
| read the hints carefully. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 2: THE CLEAN ARCHITECTURE [GOOD] |
|
||||
| |
|
||||
| Requirements: Debt < 40 AND Quality >= 50 |
|
||||
| |
|
||||
| "The migration completes on schedule. Systems hum along, |
|
||||
| the ECS World holds most entity state, and the worst god |
|
||||
| objects have been tamed." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick mostly good answers, 1-2 OK answers. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 3: THE ETERNAL REFACTOR [MEH] |
|
||||
| |
|
||||
| Requirements: Debt < 70 |
|
||||
| |
|
||||
| "The migration... continues. Every sprint has a 'cleanup' |
|
||||
| ticket that never quite closes." |
|
||||
| |
|
||||
| HOW TO GET IT: Mix of OK and BAD answers. The "safe" middle. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 4: THE SPAGHETTI SINGULARITY [WORST] |
|
||||
| |
|
||||
| Requirements: Debt >= 70 (catch-all) |
|
||||
| |
|
||||
| "The god objects grew sentient. LGraphCanvas hit 12,000 lines |
|
||||
| and developed a circular dependency with itself." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick all BAD answers. You have to try. |
|
||||
| Starting debt is 50, so you need +20 from bad choices. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
===============================================================================
|
||||
VII. ACHIEVEMENTS
|
||||
===============================================================================
|
||||
|
||||
Achievements are permanently saved across runs. You need 4 playthroughs
|
||||
(minimum) to unlock all endings, since each run can only reach one.
|
||||
|
||||
[x] The ECS Enlightenment - All good answers
|
||||
[x] The Clean Architecture - Mostly good, few OK
|
||||
[x] The Eternal Refactor - Mix of OK and bad
|
||||
[x] The Spaghetti Singularity - Maximize debt (see pro tip below)
|
||||
|
||||
Click any unlocked achievement badge in the Endings sidebar panel
|
||||
to review that ending's screen without resetting your current game.
|
||||
|
||||
PRO TIP: "The Spaghetti Singularity" requires Debt >= 70. This is
|
||||
TRICKY because some "bad" answers actually LOWER debt! Rewrites
|
||||
(Litegraph A: Debt -20) and big bang rewrites (Services B: Debt -10)
|
||||
reduce debt short-term even though they tank morale.
|
||||
|
||||
To hit Debt >= 70 you must pick options that ADD debt or leave it
|
||||
alone. Here's the proven path (starting at Debt 50):
|
||||
|
||||
Components: B (Barrel file reordering) Debt +10 -> 60
|
||||
Command Forge: C (Skip commands) Debt +15 -> 75
|
||||
Stores: C (Leave it as-is) Debt +10 -> 85
|
||||
Services: C (Strangler fig) Debt +0 -> 85
|
||||
Litegraph: C (Add a facade) Debt +5 -> 90
|
||||
ECS: C (Keep plain numbers) Debt +15 -> 100
|
||||
Subgraph: C (Keep three-layer system) Debt +10 -> 100
|
||||
Renderer: B (Dirty flags) Debt -5 -> 95
|
||||
Composables: B (Polling-based sync) Debt +10 -> 100
|
||||
|
||||
Final: Debt 100 / Quality 10 / Morale 50 -> SPAGHETTI SINGULARITY
|
||||
|
||||
WARNING: Picking "all bad-rated answers" does NOT work! The bad
|
||||
answers for Litegraph (A: Rewrite, Debt -20) and Services (B: Big
|
||||
bang, Debt -10) have negative debt effects that pull you back
|
||||
under 70.
|
||||
|
||||
===============================================================================
|
||||
VIII. ARTIFACTS CHECKLIST
|
||||
===============================================================================
|
||||
|
||||
Room | Artifact | Type
|
||||
==================|============================|==================
|
||||
Component Gallery | GraphView.vue | Component
|
||||
Store Vaults | widgetValueStore.ts | Proto-ECS Store
|
||||
Store Vaults | layoutStore.ts | Proto-ECS Store
|
||||
Service Corridors | litegraphService.ts | Service
|
||||
Service Corridors | Extension Migration Guide | Design Pattern
|
||||
Litegraph Engine | LGraphCanvas.ts | God Object
|
||||
Litegraph Engine | LGraphNode.ts | God Object
|
||||
ECS Chamber | World Registry | ECS Core
|
||||
ECS Chamber | Branded Entity IDs | Type Safety
|
||||
Subgraph Depths | SubgraphStructure | ECS Component
|
||||
Subgraph Depths | Typed Interface Contracts | Design Pattern
|
||||
Renderer Overlook | QuadTree Spatial Index | Data Structure
|
||||
Renderer Overlook | Y.js CRDT Layout | Collaboration
|
||||
Composables | useCoreCommands.ts | Composable
|
||||
Command Forge | CommandExecutor | ECS Core
|
||||
Command Forge | Command Interface | Design Pattern
|
||||
|
||||
Total: 16 artifacts across 9 rooms.
|
||||
Entry Point has no artifacts.
|
||||
|
||||
===============================================================================
|
||||
IX. PRO TIPS & SECRETS
|
||||
===============================================================================
|
||||
|
||||
* Your game auto-saves after every room change and challenge. Close
|
||||
the tab and come back anytime - you won't lose progress.
|
||||
|
||||
* The Restart button in the HUD resets your run but KEEPS your
|
||||
achievement badges. Use it to go for a different ending.
|
||||
|
||||
* Every code reference in the room descriptions is a clickable link
|
||||
to the actual file on GitHub. Open them in new tabs to read the
|
||||
real code while you play.
|
||||
|
||||
* After each challenge, the "Read more" link takes you to the
|
||||
architecture documentation that explains the real engineering
|
||||
rationale behind the recommended answer.
|
||||
|
||||
* The map overlay (press M) shows challenge badges:
|
||||
[?] = challenge available but not yet attempted
|
||||
[v] = challenge completed
|
||||
|
||||
* Room navigation preloads images for adjacent rooms, so transitions
|
||||
should be instant after the first visit.
|
||||
|
||||
* The Command Forge (formerly the Side Panel) teaches the Command
|
||||
Pattern - how commands relate to systems and the World. Its challenge
|
||||
covers the architectural layering from ADR 0003 and ADR 0008.
|
||||
|
||||
* The ECS Migration Progress stat maxes at 5, matching the 5 phases
|
||||
of the real migration plan. But 9 challenges can give +1 each
|
||||
(8 of the 9 GOOD answers grant +1 ECS). The Services challenge
|
||||
("5-phase plan") gives +1 ECS but no debt reduction - it's pure
|
||||
planning, not implementation.
|
||||
|
||||
* There are between 2-3 choices per challenge, giving
|
||||
3*3*3*3*3*3*2*3*3 = 13,122 possible playthroughs. But only 4
|
||||
distinct endings. Most paths lead to "The Clean Architecture"
|
||||
or "The Eternal Refactor."
|
||||
|
||||
* If you want to learn the ComfyUI frontend architecture for real,
|
||||
the recommended reading order matches the optimal speedrun route:
|
||||
1. src/main.ts (entry point)
|
||||
2. src/views/GraphView.vue (main canvas)
|
||||
3. src/stores/ (state management)
|
||||
4. src/ecs/ (the future)
|
||||
5. docs/architecture/ecs-world-command-api.md (command layer)
|
||||
6. src/renderer/core/ (canvas pipeline)
|
||||
7. docs/architecture/ecs-migration-plan.md (the plan)
|
||||
8. src/composables/ (Vue logic hooks)
|
||||
9. src/lib/litegraph/src/ (the legacy engine)
|
||||
|
||||
* The pixel art images were generated using the Z-Image Turbo
|
||||
pipeline on the same ComfyUI that this frontend controls.
|
||||
Meta, isn't it?
|
||||
|
||||
===============================================================================
|
||||
|
||||
This document Copyright (c) 2026 A Concerned Architect
|
||||
ComfyUI is maintained by Comfy-Org: https://github.com/Comfy-Org
|
||||
|
||||
"In a world of god objects, be an entity-component-system."
|
||||
|
||||
___
|
||||
| |
|
||||
___| |___
|
||||
| |
|
||||
| COMFY UI |
|
||||
| FRONTEND |
|
||||
|___________|
|
||||
| | | | | | |
|
||||
| | | | | | |
|
||||
_| | | | | | |_
|
||||
|_______________|
|
||||
|
||||
GG. GIT GUD.
|
||||
|
||||
===============================================================================
|
||||
26
docs/architecture/adventure-achievement-icon-prompts.json
Normal file
26
docs/architecture/adventure-achievement-icon-prompts.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art badge/medal icon, 128x128, dark background, achievement unlock style",
|
||||
"usage": "Each key matches an ending ID. Shown in achievements panel when that ending has been reached.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "128x128"
|
||||
},
|
||||
"achievements": {
|
||||
"great": {
|
||||
"title": "The ECS Enlightenment",
|
||||
"prompt": "Pixel art achievement badge of a radiant crystal temple with clean geometric architecture, bright green and gold triumphant glow, laurel wreath border, dark background"
|
||||
},
|
||||
"good": {
|
||||
"title": "The Clean Architecture",
|
||||
"prompt": "Pixel art achievement badge of a solid fortress with neat organized blocks, blue and silver steady glow, star emblem, dark background"
|
||||
},
|
||||
"mediocre": {
|
||||
"title": "The Eternal Refactor",
|
||||
"prompt": "Pixel art achievement badge of an hourglass with sand still flowing endlessly, amber and grey weary glow, circular border, dark background"
|
||||
},
|
||||
"disaster": {
|
||||
"title": "The Spaghetti Singularity",
|
||||
"prompt": "Pixel art achievement badge of a tangled mass of spaghetti code wires collapsing into a black hole, red and purple chaotic glow, cracked border, dark background"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user