Compare commits
86 Commits
fix/preser
...
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 | ||
|
|
ba9f3481fb | ||
|
|
7cbd61aaea | ||
|
|
b09562a1bf | ||
|
|
cc8ef09d28 | ||
|
|
64917e5b6c | ||
|
|
0e7cab96b7 | ||
|
|
e0d16b7ee9 | ||
|
|
8eb1525171 | ||
|
|
48219109d3 | ||
|
|
81e6282599 | ||
|
|
b8480f889e | ||
|
|
b49ea9fabd | ||
|
|
8da4640a76 | ||
|
|
65f18d17af | ||
|
|
54a00aac75 | ||
|
|
d2358c83e8 | ||
|
|
b2f848893a | ||
|
|
5c0e15f403 | ||
|
|
dc09eb60e4 | ||
|
|
30b17407db | ||
|
|
5b4ebf4d99 | ||
|
|
6836419e96 | ||
|
|
4c59a5e424 | ||
|
|
82242f1b00 | ||
|
|
f9c334092c | ||
|
|
04aee0308b | ||
|
|
caa6f89436 | ||
|
|
c4d0b3c97a | ||
|
|
3eb7c29ea4 | ||
|
|
cc2cb7e89f | ||
|
|
d2f4d41960 | ||
|
|
070a5f59fe | ||
|
|
7864e780e7 | ||
|
|
db1257fdb3 | ||
|
|
7e7e2d5647 |
74
.agents/checks/playwright-e2e.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: playwright-e2e
|
||||
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
|
||||
severity-default: medium
|
||||
tools: [Read, Grep]
|
||||
---
|
||||
|
||||
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
|
||||
|
||||
Reference docs (read if you need full context):
|
||||
|
||||
- `browser_tests/README.md` — setup, patterns, screenshot workflow
|
||||
- `browser_tests/AGENTS.md` — directory structure, fixture overview
|
||||
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
|
||||
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
|
||||
|
||||
## Checks
|
||||
|
||||
### Flakiness Risks (Major)
|
||||
|
||||
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
|
||||
|
||||
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
|
||||
|
||||
3. **Keyboard actions without prior focus** — `page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
|
||||
|
||||
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
|
||||
|
||||
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
|
||||
|
||||
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
|
||||
|
||||
7. **Double-click without `{ delay }` option** — `dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
|
||||
|
||||
### Fixture & API Misuse (Medium)
|
||||
|
||||
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
|
||||
- `comfyPage.command.executeCommand()` for menu/command actions
|
||||
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
|
||||
- `comfyPage.canvasOps.resetView()` for view reset
|
||||
- `comfyPage.settings.setSetting()` for settings
|
||||
- Component page objects in `browser_tests/fixtures/components/`
|
||||
|
||||
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
|
||||
|
||||
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
|
||||
|
||||
### Convention Violations (Minor)
|
||||
|
||||
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
|
||||
|
||||
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
|
||||
|
||||
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
|
||||
|
||||
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
|
||||
|
||||
15. **Debug helpers left in committed code** — `debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
|
||||
|
||||
### Test Design (Nitpick)
|
||||
|
||||
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
|
||||
|
||||
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
|
||||
|
||||
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
|
||||
|
||||
## Rules
|
||||
|
||||
- Only review `.spec.ts` files and supporting code in `browser_tests/`
|
||||
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
|
||||
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
|
||||
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
|
||||
- Existing tests that predate conventions are acceptable to modify but not required to fix
|
||||
84
.claude/skills/adding-deprecation-warnings/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: adding-deprecation-warnings
|
||||
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
|
||||
---
|
||||
|
||||
# Adding Deprecation Warnings
|
||||
|
||||
Adds backward-compatible deprecation warnings for renamed or removed
|
||||
properties using the `defineDeprecatedProperty` helper in
|
||||
`src/lib/litegraph/src/utils/feedback.ts`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A property or API has been renamed and custom nodes still use the old name
|
||||
- A property is being removed but needs a grace period
|
||||
- Backward compatibility must be preserved while nudging adoption
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Search the Custom Node Ecosystem
|
||||
|
||||
Before implementing, assess impact by searching for usage of the
|
||||
deprecated property across ComfyUI custom nodes:
|
||||
|
||||
```text
|
||||
Use the comfy_codesearch tool to search for the old property name.
|
||||
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
|
||||
```
|
||||
|
||||
Document the usage patterns found (property access, truthiness checks,
|
||||
caching to local vars, style mutation, etc.) — these all must continue
|
||||
working.
|
||||
|
||||
### 2. Apply the Deprecation
|
||||
|
||||
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
/** @deprecated Use {@link obj.newProp} instead. */
|
||||
defineDeprecatedProperty(
|
||||
obj,
|
||||
'oldProp',
|
||||
'newProp',
|
||||
'obj.oldProp is deprecated. Use obj.newProp instead.'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Checklist
|
||||
|
||||
- [ ] Ecosystem search completed — all usage patterns are compatible
|
||||
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
|
||||
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
|
||||
- [ ] Warning message names both old and new property clearly
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm lint` passes
|
||||
|
||||
### 4. PR Comment
|
||||
|
||||
Add a PR comment summarizing the ecosystem search results: which repos
|
||||
use the deprecated property, what access patterns were found, and
|
||||
confirmation that all patterns are compatible with the ODP getter/setter.
|
||||
|
||||
## How `defineDeprecatedProperty` Works
|
||||
|
||||
- Creates an `Object.defineProperty` getter/setter on the target object
|
||||
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
|
||||
- Both log via `warnDeprecated`, which deduplicates (once per unique
|
||||
message per session via a `Set`)
|
||||
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
|
||||
/ `JSON.stringify`
|
||||
- `configurable: true` allows further redefinition if needed
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
|
||||
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
|
||||
once then the cached ref is used directly
|
||||
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
|
||||
works, getter returns the real object
|
||||
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
|
||||
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
|
||||
once per session regardless of call count
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -13,8 +13,6 @@ runs:
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -17,8 +17,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -22,8 +22,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -20,8 +20,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -76,8 +74,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
18
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -33,6 +33,20 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-dist-cloud
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Sharded chromium tests
|
||||
playwright-tests-chromium-sharded:
|
||||
needs: setup
|
||||
@@ -97,14 +111,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Download built frontend
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
|
||||
path: dist/
|
||||
|
||||
- name: Start ComfyUI server
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -30,8 +30,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -85,8 +85,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -76,8 +76,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -203,8 +201,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
4
.github/workflows/release-draft-create.yaml
vendored
@@ -20,10 +20,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
@@ -76,8 +76,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
4
.github/workflows/release-pypi-dev.yaml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
@@ -144,8 +144,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -52,8 +52,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -30,8 +30,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
1
.gitignore
vendored
@@ -66,6 +66,7 @@ dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
.superpowers/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
apps/website/public/icons/social/discord.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028ZM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38Zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
apps/website/public/icons/social/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 819 B |
1
apps/website/public/icons/social/instagram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z"/></svg>
|
||||
|
After Width: | Height: | Size: 988 B |
1
apps/website/public/icons/social/linkedin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>
|
||||
|
After Width: | Height: | Size: 536 B |
1
apps/website/public/icons/social/reddit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm5.8 11.33c.02.16.03.33.03.5 0 2.55-2.97 4.63-6.63 4.63-3.65 0-6.62-2.07-6.62-4.63 0-.17.01-.34.03-.5a1.58 1.58 0 0 1-.63-1.27c0-.88.72-1.59 1.6-1.59.44 0 .83.18 1.12.46 1.1-.79 2.62-1.3 4.31-1.37l.73-3.44a.32.32 0 0 1 .39-.24l2.43.52a1.13 1.13 0 0 1 2.15.36 1.13 1.13 0 0 1-1.13 1.12 1.13 1.13 0 0 1-1.08-.82l-2.16-.46-.65 3.07c1.65.09 3.14.59 4.22 1.36.29-.28.69-.46 1.13-.46.88 0 1.6.71 1.6 1.59 0 .52-.25.97-.63 1.27ZM9.5 13.5c0 .63.51 1.13 1.13 1.13s1.12-.5 1.12-1.13-.5-1.12-1.12-1.12-1.13.5-1.13 1.12Zm5.75 2.55c-.69.69-2 .73-3.25.73s-2.56-.04-3.25-.73a.32.32 0 1 1 .45-.45c.44.44 1.37.6 2.8.6 1.43 0 2.37-.16 2.8-.6a.32.32 0 1 1 .45.45Zm-.37-1.42c.62 0 1.13-.5 1.13-1.13 0-.62-.51-1.12-1.13-1.12-.63 0-1.13.5-1.13 1.12 0 .63.5 1.13 1.13 1.13Z"/></svg>
|
||||
|
After Width: | Height: | Size: 915 B |
1
apps/website/public/icons/social/x.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
143
apps/website/src/components/SiteFooter.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
const columns = [
|
||||
{
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'Comfy Desktop', href: '/download' },
|
||||
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
|
||||
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
|
||||
{ label: 'Pricing', href: '/pricing' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{ label: 'Documentation', href: 'https://docs.comfy.org' },
|
||||
{ label: 'Blog', href: 'https://blog.comfy.org' },
|
||||
{ label: 'Gallery', href: '/gallery' },
|
||||
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Legal',
|
||||
links: [
|
||||
{ label: 'Terms of Service', href: '/terms-of-service' },
|
||||
{ label: 'Privacy Policy', href: '/privacy-policy' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const socials = [
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
icon: '/icons/social/github.svg'
|
||||
},
|
||||
{
|
||||
label: 'Discord',
|
||||
href: 'https://discord.gg/comfyorg',
|
||||
icon: '/icons/social/discord.svg'
|
||||
},
|
||||
{
|
||||
label: 'X',
|
||||
href: 'https://x.com/comaboratory',
|
||||
icon: '/icons/social/x.svg'
|
||||
},
|
||||
{
|
||||
label: 'Reddit',
|
||||
href: 'https://reddit.com/r/comfyui',
|
||||
icon: '/icons/social/reddit.svg'
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
href: 'https://linkedin.com/company/comfyorg',
|
||||
icon: '/icons/social/linkedin.svg'
|
||||
},
|
||||
{
|
||||
label: 'Instagram',
|
||||
href: 'https://instagram.com/comfyorg',
|
||||
icon: '/icons/social/instagram.svg'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-white/10 bg-black">
|
||||
<div
|
||||
class="mx-auto grid max-w-7xl gap-8 px-6 py-16 sm:grid-cols-2 lg:grid-cols-5"
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="lg:col-span-1">
|
||||
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
|
||||
Comfy
|
||||
</a>
|
||||
<p class="mt-4 text-sm text-smoke-700">
|
||||
Professional control of visual AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Link columns -->
|
||||
<nav
|
||||
v-for="column in columns"
|
||||
:key="column.title"
|
||||
:aria-label="column.title"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-white">{{ column.title }}</h3>
|
||||
<a
|
||||
v-for="link in column.links"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
class="text-sm text-smoke-700 transition-colors hover:text-white"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="border-t border-white/10">
|
||||
<div
|
||||
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
© {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
|
||||
</p>
|
||||
|
||||
<!-- Social icons -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
v-for="social in socials"
|
||||
:key="social.label"
|
||||
:href="social.href"
|
||||
:aria-label="social.label"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-smoke-700 transition-colors hover:text-white"
|
||||
>
|
||||
<span
|
||||
class="inline-block size-5 bg-current"
|
||||
:style="{
|
||||
maskImage: `url(${social.icon})`,
|
||||
maskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskImage: `url(${social.icon})`,
|
||||
WebkitMaskSize: 'contain',
|
||||
WebkitMaskRepeat: 'no-repeat'
|
||||
}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
149
apps/website/src/components/SiteNav.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const currentPath = ref('')
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'ENTERPRISE', href: '/enterprise' },
|
||||
{ label: 'GALLERY', href: '/gallery' },
|
||||
{ label: 'ABOUT', href: '/about' },
|
||||
{ label: 'CAREERS', href: '/careers' }
|
||||
]
|
||||
|
||||
const ctaLinks = [
|
||||
{
|
||||
label: 'COMFY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
label: 'COMFY HUB',
|
||||
href: 'https://hub.comfy.org',
|
||||
primary: false
|
||||
}
|
||||
]
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && mobileMenuOpen.value) {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onAfterSwap() {
|
||||
mobileMenuOpen.value = false
|
||||
currentPath.value = window.location.pathname
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
document.addEventListener('astro:after-swap', onAfterSwap)
|
||||
currentPath.value = window.location.pathname
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
document.removeEventListener('astro:after-swap', onAfterSwap)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
|
||||
Comfy
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div class="hidden items-center gap-8 md:flex">
|
||||
<a
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:aria-current="currentPath === link.href ? 'page' : undefined"
|
||||
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
v-for="cta in ctaLinks"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
{{ cta.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
class="flex flex-col gap-1.5 md:hidden"
|
||||
aria-label="Toggle menu"
|
||||
aria-controls="site-mobile-menu"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<span
|
||||
class="block h-0.5 w-6 bg-white transition-transform"
|
||||
:class="mobileMenuOpen && 'translate-y-2 rotate-45'"
|
||||
/>
|
||||
<span
|
||||
class="block h-0.5 w-6 bg-white transition-opacity"
|
||||
:class="mobileMenuOpen && 'opacity-0'"
|
||||
/>
|
||||
<span
|
||||
class="block h-0.5 w-6 bg-white transition-transform"
|
||||
:class="mobileMenuOpen && '-translate-y-2 -rotate-45'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div
|
||||
v-show="mobileMenuOpen"
|
||||
id="site-mobile-menu"
|
||||
class="border-t border-white/10 bg-black px-6 pb-6 md:hidden"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<a
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:aria-current="currentPath === link.href ? 'page' : undefined"
|
||||
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-3 pt-2">
|
||||
<a
|
||||
v-for="cta in ctaLinks"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
|
||||
>
|
||||
{{ cta.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
86
apps/website/src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
import Analytics from '@vercel/analytics/astro'
|
||||
import '../styles/global.css'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
ogImage?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
ogImage = '/og-default.png',
|
||||
} = Astro.props
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const locale = Astro.currentLocale ?? 'en'
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImageURL.href} />
|
||||
<meta property="og:url" content={canonicalURL.href} />
|
||||
<meta property="og:locale" content={locale} />
|
||||
<meta property="og:site_name" content="Comfy" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmEnabled && (
|
||||
<script is:inline define:vars={{ gtmId }}>
|
||||
;(function (w, d, s, l, i) {
|
||||
w[l] = w[l] || []
|
||||
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' })
|
||||
var f = d.getElementsByTagName(s)[0],
|
||||
j = d.createElement(s),
|
||||
dl = l != 'dataLayer' ? '&l=' + l : ''
|
||||
j.async = true
|
||||
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
|
||||
f.parentNode.insertBefore(j, f)
|
||||
})(window, document, 'script', 'dataLayer', gtmId)
|
||||
</script>
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body class="bg-black text-white font-inter antialiased">
|
||||
{gtmEnabled && (
|
||||
<noscript>
|
||||
<iframe
|
||||
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
|
||||
height="0"
|
||||
width="0"
|
||||
style="display:none;visibility:hidden"
|
||||
></iframe>
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
<slot />
|
||||
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,5 +5,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
"include": ["src/**/*", "astro.config.ts"]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,24 @@ browser_tests/
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
## Polling Assertions
|
||||
|
||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||
|
||||
```typescript
|
||||
// ✅ Correct — single async call + single assertion
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
|
||||
.toBe(0)
|
||||
|
||||
// ❌ Avoid — nested expect inside toPass
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 250 })
|
||||
```
|
||||
|
||||
Reserve `toPass()` for blocks with multiple assertions or complex async logic that can't be expressed as a single polled value.
|
||||
|
||||
## Gotchas
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|
||||
42
browser_tests/assets/widgets/image_compare_widget.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCompare",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "a_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "b_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCompare"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -31,10 +31,14 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a Vue node by the node's title (displayed name in the header)
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
@@ -33,6 +34,20 @@ export class ContextMenu {
|
||||
return primeVueVisible || litegraphVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await expect
|
||||
.soft(this.page.getByRole('menuitem', { name: item }))
|
||||
.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
return this
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
||||
const count = await locator.count()
|
||||
|
||||
@@ -3,17 +3,28 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from './BuilderStepsHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
private get builderToolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
@@ -24,42 +35,6 @@ export class AppModeHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Exit builder mode via the footer "Exit app builder" button. */
|
||||
async exitBuilder() {
|
||||
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Inputs" step in the builder toolbar. */
|
||||
async goToInputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Outputs" step in the builder toolbar. */
|
||||
async goToOutputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Preview" step in the builder toolbar. */
|
||||
async goToPreview() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Next" button in the builder footer. */
|
||||
async next() {
|
||||
await this.page.getByRole('button', { name: 'Next' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Back" button in the builder footer. */
|
||||
async back() {
|
||||
await this.page.getByRole('button', { name: 'Back' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Toggle app mode (linear view) on/off. */
|
||||
async toggleAppMode() {
|
||||
await this.page.evaluate(() => {
|
||||
@@ -118,84 +93,4 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder input-select
|
||||
* sidebar (IoItem).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getBuilderInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({ hasText: title })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder preview/arrange
|
||||
* sidebar (AppModeWidgetList with builderMode).
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.locator(`[aria-label="${ariaLabel}"]`)
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
69
browser_tests/fixtures/helpers/BuilderFooterHelper.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class BuilderFooterHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get nav(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.footerNav)
|
||||
}
|
||||
|
||||
get exitButton(): Locator {
|
||||
return this.buttonByName('Exit app builder')
|
||||
}
|
||||
|
||||
get nextButton(): Locator {
|
||||
return this.buttonByName('Next')
|
||||
}
|
||||
|
||||
get backButton(): Locator {
|
||||
return this.buttonByName('Back')
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
get saveAsChevron(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
}
|
||||
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private buttonByName(name: string): Locator {
|
||||
return this.nav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
async next() {
|
||||
await this.nextButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async back() {
|
||||
await this.backButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async exitBuilder() {
|
||||
await this.exitButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async openSaveAsFromChevron() {
|
||||
await this.saveAsChevron.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Save as' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
78
browser_tests/fixtures/helpers/BuilderSaveAsHelper.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
get dialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save"]')
|
||||
}
|
||||
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
get successDialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save-success"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.dialog.getByText('Save as')
|
||||
}
|
||||
|
||||
get radioGroup(): Locator {
|
||||
return this.dialog.getByRole('radiogroup')
|
||||
}
|
||||
|
||||
get nameInput(): Locator {
|
||||
return this.dialog.getByRole('textbox')
|
||||
}
|
||||
|
||||
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
||||
return this.dialog.getByRole('radio', { name: viewType })
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.dialog.getByRole('button', { name: 'Save' })
|
||||
}
|
||||
|
||||
get successMessage(): Locator {
|
||||
return this.successDialog.getByText('Successfully saved')
|
||||
}
|
||||
|
||||
get viewAppButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'View app' })
|
||||
}
|
||||
|
||||
get closeButton(): Locator {
|
||||
return this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
}
|
||||
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
get dismissButton(): Locator {
|
||||
return this.successDialog.locator('button.p-dialog-close-button')
|
||||
}
|
||||
|
||||
get exitBuilderButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'Exit builder' })
|
||||
}
|
||||
|
||||
get overwriteDialog(): Locator {
|
||||
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
|
||||
}
|
||||
|
||||
get overwriteButton(): Locator {
|
||||
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
|
||||
}
|
||||
|
||||
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
||||
await this.nameInput.fill(workflowName)
|
||||
await this.viewTypeRadio(viewType).click()
|
||||
await this.saveButton.click()
|
||||
}
|
||||
}
|
||||
139
browser_tests/fixtures/helpers/BuilderSelectHelper.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the preview/arrange sidebar.
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.getByLabel(ariaLabel, { exact: true })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/** Delete a builder input via its actions menu. */
|
||||
async deleteInput(title: string) {
|
||||
const menu = this.getInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Delete', { exact: true }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title for inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget via its actions popover (works in preview and app mode).
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async selectInputWidget(node: NodeReference) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await this.page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async selectOutputNode() {
|
||||
const saveImageNodeId = await this.page.evaluate(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
const saveImageRef =
|
||||
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await this.page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
30
browser_tests/fixtures/helpers/BuilderStepsHelper.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class BuilderStepsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get toolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToOutputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToPreview() {
|
||||
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,39 @@ export class CanvasHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan the canvas back and forth in a sweep pattern using middle-mouse drag.
|
||||
* Each step advances one animation frame, giving per-frame measurement
|
||||
* granularity for performance tests.
|
||||
*/
|
||||
async panSweep(options?: {
|
||||
steps?: number
|
||||
dx?: number
|
||||
dy?: number
|
||||
}): Promise<void> {
|
||||
const { steps = 120, dx = 8, dy = 3 } = options ?? {}
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
const centerX = box.x + box.width / 2
|
||||
const centerY = box.y + box.height / 2
|
||||
await this.page.mouse.move(centerX, centerY)
|
||||
await this.page.mouse.down({ button: 'middle' })
|
||||
|
||||
// Sweep forward
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await this.nextFrame()
|
||||
}
|
||||
// Sweep back
|
||||
for (let i = steps; i > 0; i--) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
}
|
||||
|
||||
async disconnectEdge(): Promise<void> {
|
||||
await this.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { ConsoleMessage, Locator, Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -413,4 +414,138 @@ export class SubgraphHelper {
|
||||
return window.app!.canvas.graph!.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
|
||||
async getSlotCount(type: 'input' | 'output'): Promise<number> {
|
||||
return this.page.evaluate((slotType: 'input' | 'output') => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return 0
|
||||
return graph[`${slotType}s`]?.length ?? 0
|
||||
}, type)
|
||||
}
|
||||
|
||||
async getSlotLabel(
|
||||
type: 'input' | 'output',
|
||||
index = 0
|
||||
): Promise<string | null> {
|
||||
return this.page.evaluate(
|
||||
([slotType, idx]) => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const slot = graph[`${slotType}s`]?.[idx]
|
||||
return slot?.label ?? slot?.name ?? null
|
||||
},
|
||||
[type, index] as const
|
||||
)
|
||||
}
|
||||
|
||||
async removeSlot(type: 'input' | 'output', slotName?: string): Promise<void> {
|
||||
if (type === 'input') {
|
||||
await this.rightClickInputSlot(slotName)
|
||||
} else {
|
||||
await this.rightClickOutputSlot(slotName)
|
||||
}
|
||||
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async findSubgraphNodeId(): Promise<string> {
|
||||
const id = await this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const node = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!id) throw new Error('No subgraph node found in current graph')
|
||||
return id
|
||||
}
|
||||
|
||||
async serializeAndReload(): Promise<void> {
|
||||
const serialized = await this.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await this.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized as ComfyWorkflowJSON
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
|
||||
await this.comfyPage.workflow.loadWorkflow('default')
|
||||
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await this.comfyPage.nextFrame()
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
|
||||
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
|
||||
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.graph!.convertToSubgraph(canvas.selectedItems)
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.exitViaBreadcrumb()
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
static getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
|
||||
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input.widget || input.type !== 'STRING') continue
|
||||
return {
|
||||
hasPos: !!input.pos,
|
||||
posY: input.pos?.[1] ?? null,
|
||||
widgetName: input.widget.name,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
static async expectWidgetBelowHeader(
|
||||
nodeLocator: Locator,
|
||||
widgetLocator: Locator
|
||||
): Promise<void> {
|
||||
const headerBox = await nodeLocator
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await widgetLocator.boundingBox()
|
||||
if (!headerBox || !widgetBox)
|
||||
throw new Error('Header or widget bounding box not found')
|
||||
expect(widgetBox.y).toBeGreaterThan(headerBox.y + headerBox.height)
|
||||
}
|
||||
|
||||
static collectConsoleWarnings(
|
||||
page: Page,
|
||||
patterns: string[] = [
|
||||
'No link found',
|
||||
'Failed to resolve legacy -1',
|
||||
'No inner link found'
|
||||
]
|
||||
): { warnings: string[]; dispose: () => void } {
|
||||
const warnings: string[] = []
|
||||
const handler = (msg: ConsoleMessage) => {
|
||||
const text = msg.text()
|
||||
if (patterns.some((p) => text.includes(p))) {
|
||||
warnings.push(text)
|
||||
}
|
||||
}
|
||||
page.on('console', handler)
|
||||
return { warnings, dispose: () => page.off('console', handler) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import type { AppMode } from '../../../src/composables/useAppMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
@@ -104,6 +105,40 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowPath(): Promise<string | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.path
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowActiveAppMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.activeMode
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowInitialMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.initialMode
|
||||
})
|
||||
}
|
||||
|
||||
async getLinearModeFromGraph(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return window.app!.rootGraph.extra?.linearMode as boolean | undefined
|
||||
})
|
||||
}
|
||||
|
||||
async getOpenWorkflowCount(): Promise<number> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow.workflows
|
||||
.length
|
||||
})
|
||||
}
|
||||
|
||||
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
|
||||
@@ -51,7 +51,8 @@ export const TestIds = {
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button'
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
@@ -77,9 +78,14 @@ export const TestIds = {
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
saveAsButton: 'builder-save-as-button',
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
|
||||
@@ -392,6 +392,11 @@ export class NodeReference {
|
||||
await this.comfyPage.clipboard.copy()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
await this.click('title')
|
||||
await this.comfyPage.page.keyboard.press('Delete')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async connectWidget(
|
||||
originSlotIndex: number,
|
||||
targetNode: NodeReference,
|
||||
|
||||
78
browser_tests/helpers/builderTestUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
* Loads the default workflow, optionally transforms it (e.g. convert a node
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(inputNode)
|
||||
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
return inputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
})
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
@@ -12,6 +12,38 @@ export interface PerfReport {
|
||||
|
||||
const TEMP_DIR = join('test-results', 'perf-temp')
|
||||
|
||||
type MeasurementField = keyof PerfMeasurement
|
||||
|
||||
const FIELD_FORMATTERS: Record<string, (m: PerfMeasurement) => string> = {
|
||||
styleRecalcs: (m) => `${m.styleRecalcs} recalcs`,
|
||||
layouts: (m) => `${m.layouts} layouts`,
|
||||
taskDurationMs: (m) => `${m.taskDurationMs.toFixed(1)}ms task`,
|
||||
layoutDurationMs: (m) => `${m.layoutDurationMs.toFixed(1)}ms layout`,
|
||||
frameDurationMs: (m) => `${m.frameDurationMs.toFixed(1)}ms/frame`,
|
||||
totalBlockingTimeMs: (m) => `TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`,
|
||||
durationMs: (m) => `${m.durationMs.toFixed(0)}ms total`,
|
||||
heapDeltaBytes: (m) => `heap Δ${(m.heapDeltaBytes / 1024).toFixed(0)}KB`,
|
||||
domNodes: (m) => `DOM Δ${m.domNodes}`,
|
||||
heapUsedBytes: (m) => `heap ${(m.heapUsedBytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a perf measurement to the console in a consistent format.
|
||||
* Fields are formatted automatically based on their type.
|
||||
*/
|
||||
export function logMeasurement(
|
||||
label: string,
|
||||
m: PerfMeasurement,
|
||||
fields: MeasurementField[]
|
||||
) {
|
||||
const parts = fields.map((f) => {
|
||||
const formatter = FIELD_FORMATTERS[f]
|
||||
if (formatter) return formatter(m)
|
||||
return `${f}=${m[f]}`
|
||||
})
|
||||
console.log(`${label}: ${parts.join(', ')}`)
|
||||
}
|
||||
|
||||
export function recordMeasurement(m: PerfMeasurement) {
|
||||
mkdirSync(TEMP_DIR, { recursive: true })
|
||||
const filename = `${m.name}-${Date.now()}.json`
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import { isSubgraph } from '../../src/utils/typeGuardUtil'
|
||||
|
||||
/**
|
||||
* Assertion helper for tests where being in a subgraph is a precondition.
|
||||
* Throws a clear error if the graph is not a Subgraph.
|
||||
*/
|
||||
export function assertSubgraph(
|
||||
graph: LGraph | Subgraph | null | undefined
|
||||
): asserts graph is Subgraph {
|
||||
if (!isSubgraph(graph)) {
|
||||
throw new Error(
|
||||
'Expected to be in a subgraph context, but graph is not a Subgraph'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the widget-input slot Y position and the node title height
|
||||
* for the promoted "text" input on the SubgraphNode.
|
||||
*
|
||||
* The slot Y should be at the widget row, not the header. A value near
|
||||
* zero or negative indicates the slot is positioned at the header (the bug).
|
||||
*/
|
||||
export function getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
|
||||
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input.widget || input.type !== 'STRING') continue
|
||||
return {
|
||||
hasPos: !!input.pos,
|
||||
posY: input.pos?.[1] ?? null,
|
||||
widgetName: input.widget.name,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}
|
||||
@@ -1,89 +1,11 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Convert the KSampler (id 3) in the default workflow to a subgraph,
|
||||
* enter builder, select the promoted seed widget as input and
|
||||
* SaveImage/PreviewImage as output.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
await appMode.goToOutputs()
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
import {
|
||||
saveAndReopenInAppMode,
|
||||
setupSubgraphBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -107,14 +29,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Go back to inputs step where IoItems are shown
|
||||
await appMode.goToInputs()
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
const menu = appMode.select.getInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
@@ -130,11 +52,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToInputs()
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
|
||||
await appMode.select.renameInput('seed', 'Dblclick Seed')
|
||||
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
@@ -146,14 +68,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToPreview()
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
|
||||
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Preview Seed')
|
||||
await appMode.select.renameWidget(menu, 'Preview Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-preview`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
@@ -166,13 +88,13 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Enter app mode from builder
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
await appMode.toggleAppMode()
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const menu = appMode.getAppModeWidgetMenu('seed')
|
||||
await appMode.renameWidget(menu, 'App Mode Seed')
|
||||
await appMode.select.renameWidget(menu, 'App Mode Seed')
|
||||
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
|
||||
|
||||
@@ -19,24 +19,26 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
.toBe(`*${workflowName} - ComfyUI`)
|
||||
})
|
||||
|
||||
// Failing on CI
|
||||
// Cannot reproduce locally
|
||||
test.skip('Can display workflow name with unsaved changes', async ({
|
||||
test('Can display workflow name with unsaved changes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.filename
|
||||
const workflowName = `test-${Date.now()}`
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
.toBe(`${workflowName} - ComfyUI`)
|
||||
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
node.pos[0] += 50
|
||||
window.app!.graph!.setDirtyCanvas(true, true)
|
||||
;(
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('test')
|
||||
expect(await comfyPage.page.title()).toBe('test - ComfyUI')
|
||||
|
||||
const textBox = comfyPage.widgetTextBox
|
||||
await textBox.fill('Hello World')
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
.toBe(`*${workflowName} - ComfyUI`)
|
||||
|
||||
// Delete the saved workflow for cleanup.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
|
||||
371
browser_tests/tests/builderSaveFlow.spec.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
* fill name + view type, and save.
|
||||
*/
|
||||
async function reSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.openSaveAsFromChevron()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
}
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
await expect(saveAs.title).toBeVisible()
|
||||
await expect(saveAs.radioGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save as dialog allows entering filename and saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} builder-save`, 'App')
|
||||
})
|
||||
|
||||
test('Save as dialog disables save when filename is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await saveAs.nameInput.fill('')
|
||||
await expect(saveAs.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const appRadio = saveAs.viewTypeRadio('App')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
const graphRadio = saveAs.viewTypeRadio('Node graph')
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
test('Builder step navigation works correctly', async ({ comfyPage }) => {
|
||||
const { footer } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await expect(footer.backButton).toBeDisabled()
|
||||
await expect(footer.nextButton).toBeEnabled()
|
||||
|
||||
await footer.next()
|
||||
await expect(footer.backButton).toBeEnabled()
|
||||
|
||||
await footer.next()
|
||||
await expect(footer.nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Escape key exits builder mode', async ({ comfyPage }) => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button directly saves for previously saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Modify the workflow so the save button becomes enabled
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await comfyPage.appMode.select.deleteInput('seed')
|
||||
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
await footer.saveButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(saveAs.dialog).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(footer.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Split button chevron opens save-as for saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await footer.openSaveAsFromChevron()
|
||||
|
||||
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('Connect an output', { exact: false })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('save as app produces correct extension and linearMode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
|
||||
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain('.app.json')
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
test('save as node graph produces correct extension and linearMode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(
|
||||
comfyPage.appMode,
|
||||
`${Date.now()} graph-ext`,
|
||||
'Node graph'
|
||||
)
|
||||
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toMatch(/\.json$/)
|
||||
expect(path).not.toContain('.app.json')
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(false)
|
||||
})
|
||||
|
||||
test('save as app View App button enters app mode', async ({ comfyPage }) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
|
||||
|
||||
await comfyPage.appMode.saveAs.viewAppButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.workflow.getActiveWorkflowActiveAppMode()).toBe(
|
||||
'app'
|
||||
)
|
||||
})
|
||||
|
||||
test('save as node graph Exit builder exits builder mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(
|
||||
comfyPage.appMode,
|
||||
`${Date.now()} graph-exit`,
|
||||
'Node graph'
|
||||
)
|
||||
|
||||
await comfyPage.appMode.saveAs.exitBuilderButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('save as with different mode does not modify the original workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
const originalName = `${Date.now()} original`
|
||||
await builderSaveAs(appMode, originalName, 'App')
|
||||
const originalPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(originalPath).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Re-save as node graph — creates a copy
|
||||
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const newPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(newPath).not.toBe(originalPath)
|
||||
expect(newPath).not.toContain('.app.json')
|
||||
|
||||
// Dismiss success dialog, exit app mode, reopen the original
|
||||
await appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await appMode.toggleAppMode()
|
||||
await openWorkflowFromSidebar(comfyPage, originalName)
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
test('save as with same name and same mode overwrites in place', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const name = `${Date.now()} overwrite`
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
|
||||
await reSaveAs(appMode, name, 'App')
|
||||
|
||||
await expect(appMode.saveAs.overwriteDialog).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.overwriteButton.click()
|
||||
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).toBe(pathAfterFirst)
|
||||
})
|
||||
|
||||
test('save as with same name but different mode creates a new file', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const name = `${Date.now()} mode-change`
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterFirst).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await reSaveAs(appMode, name, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).not.toBe(pathAfterFirst)
|
||||
expect(pathAfterSecond).toMatch(/\.json$/)
|
||||
expect(pathAfterSecond).not.toContain('.app.json')
|
||||
})
|
||||
|
||||
test('save as app workflow reloads in app mode', async ({ comfyPage }) => {
|
||||
const name = `${Date.now()} reload-app`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'App')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('app')
|
||||
})
|
||||
|
||||
test('save as node graph workflow reloads in node graph mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const name = `${Date.now()} reload-graph`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'Node graph')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('graph')
|
||||
})
|
||||
})
|
||||
29
browser_tests/tests/cloud.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
* These tests run against the cloud build (DISTRIBUTION=cloud) and verify
|
||||
* that cloud-specific behavior is present. In CI, no Firebase auth is
|
||||
* configured, so the auth guard redirects to /cloud/login. The tests
|
||||
* verify the cloud build loaded correctly by checking for cloud-only
|
||||
* routes and elements.
|
||||
*/
|
||||
test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -256,27 +256,6 @@ test.describe('Missing models in Error Tab', () => {
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
|
||||
test.skip('Should download missing model when clicking download button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadAllButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings', () => {
|
||||
|
||||
@@ -55,46 +55,4 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
const finalCount = await comfyPage.getDOMWidgetCount()
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('should reposition when layout changes', async ({ comfyPage }) => {
|
||||
test.skip(
|
||||
true,
|
||||
'Only recalculates when the Canvas size changes, need to recheck the logic'
|
||||
)
|
||||
// --- setup ---
|
||||
|
||||
const textareaWidget = comfyPage.page
|
||||
.locator('.comfy-multiline-input')
|
||||
.first()
|
||||
await expect(textareaWidget).toBeVisible()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let oldPos: [number, number]
|
||||
const checkBboxChange = async () => {
|
||||
const boudningBox = (await textareaWidget.boundingBox())!
|
||||
expect(boudningBox).not.toBeNull()
|
||||
const position: [number, number] = [boudningBox.x, boudningBox.y]
|
||||
expect(position).not.toEqual(oldPos)
|
||||
oldPos = position
|
||||
}
|
||||
await checkBboxChange()
|
||||
|
||||
// --- test ---
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.nextFrame()
|
||||
await checkBboxChange()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
|
||||
await comfyPage.nextFrame()
|
||||
await checkBboxChange()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Bottom')
|
||||
await comfyPage.nextFrame()
|
||||
await checkBboxChange()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,13 +94,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.click()
|
||||
})
|
||||
})
|
||||
// The 500ms fixed delay on the search results is causing flakiness
|
||||
// Potential solution: add a spinner state when the search is in progress,
|
||||
// and observe that state from the test. Blocker: the PrimeVue AutoComplete
|
||||
// does not have a v-model on the query, so we cannot observe the raw
|
||||
// query update, and thus cannot set the spinning state between the raw query
|
||||
// update and the debounced search update.
|
||||
test.skip(
|
||||
test(
|
||||
'Can be added to canvas using search',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
@@ -108,7 +102,16 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(groupNodeName)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${groupNodeName}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 71 KiB |
81
browser_tests/tests/imageCompare.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function createTestImageDataUrl(label: string, color: string): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">` +
|
||||
`<rect width="200" height="200" fill="${color}"/>` +
|
||||
`<text x="50%" y="50%" fill="white" font-size="24" ` +
|
||||
`text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
async function setImageCompareValue(
|
||||
comfyPage: ComfyPage,
|
||||
value: { beforeImages: string[]; afterImages: string[] }
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node).toContainText('No images to compare')
|
||||
await expect(node.locator('img')).toHaveCount(0)
|
||||
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(beforeImg).toBeVisible()
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
await expect(handle).toBeVisible()
|
||||
|
||||
expect(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
).toBe('50%')
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
||||
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -175,7 +175,9 @@ test.describe('Node Interaction', () => {
|
||||
// Move mouse away to avoid hover highlight on the node at the drop position.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
|
||||
@@ -220,10 +222,7 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
|
||||
})
|
||||
|
||||
// Shift drag copy link regressed. See https://github.com/Comfy-Org/ComfyUI_frontend/issues/2941
|
||||
test.skip('Can copy link by shift-drag existing link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Can copy link by shift-drag existing link', async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
DefaultGraphPositions.emptySpace
|
||||
@@ -815,11 +814,15 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.topbar.getTabNames(), { timeout: 5000 })
|
||||
.toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 91 KiB |
@@ -68,7 +68,7 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('Load workflow from URL dropped onto Vue node', async ({
|
||||
test('Load workflow from URL dropped onto Vue node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fakeUrl = 'https://example.com/workflow.png'
|
||||
|
||||
92
browser_tests/tests/maskEditor.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Mask Editor', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'opens mask editor from context menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-header')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await contextMenu.getByText('Open in Mask Editor').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 321 KiB |
@@ -481,6 +481,7 @@ This is English documentation.
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button[data-testid="info-button"]'
|
||||
)
|
||||
await helpButton.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator(
|
||||
|
||||
@@ -176,40 +176,13 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
await expectFilterChips(comfyPage, ['MODEL'])
|
||||
})
|
||||
|
||||
// Flaky test.
|
||||
// Sample test failure:
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/12696912248/job/35391990861?pr=2210
|
||||
/*
|
||||
1) [chromium-2x] › nodeSearchBox.spec.ts:135:5 › Node search box › Filtering › Outer click dismisses filter panel but keeps search box visible
|
||||
|
||||
Error: expect(locator).not.toBeVisible()
|
||||
|
||||
Locator: getByRole('dialog').locator('div').filter({ hasText: 'Add node filter condition' })
|
||||
Expected: not visible
|
||||
Received: visible
|
||||
Call log:
|
||||
- expect.not.toBeVisible with timeout 5000ms
|
||||
- waiting for getByRole('dialog').locator('div').filter({ hasText: 'Add node filter condition' })
|
||||
|
||||
|
||||
143 |
|
||||
144 | // Verify the filter selection panel is hidden
|
||||
> 145 | expect(panel.header).not.toBeVisible()
|
||||
| ^
|
||||
146 |
|
||||
147 | // Verify the node search dialog is still visible
|
||||
148 | expect(comfyPage.searchBox.input).toBeVisible()
|
||||
|
||||
at /home/runner/work/ComfyUI_frontend/ComfyUI_frontend/ComfyUI_frontend/browser_tests/nodeSearchBox.spec.ts:145:32
|
||||
*/
|
||||
test.skip('Outer click dismisses filter panel but keeps search box visible', async ({
|
||||
test('Outer click dismisses filter panel but keeps search box visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.searchBox.filterButton.click()
|
||||
const panel = comfyPage.searchBox.filterSelectionPanel
|
||||
await panel.header.waitFor({ state: 'visible' })
|
||||
const panelBounds = await panel.header.boundingBox()
|
||||
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
// Verify the filter selection panel is hidden
|
||||
await expect(panel.header).not.toBeVisible()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { recordMeasurement } from '../helpers/perfReporter'
|
||||
import { logMeasurement, recordMeasurement } from '../helpers/perfReporter'
|
||||
|
||||
test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
test('canvas idle style recalculations', async ({ comfyPage }) => {
|
||||
@@ -186,6 +186,22 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('large graph viewport pan sweep', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
|
||||
await comfyPage.perf.startMeasuring()
|
||||
await comfyPage.canvasOps.panSweep()
|
||||
|
||||
const measurement = await comfyPage.perf.stopMeasuring('viewport-pan-sweep')
|
||||
recordMeasurement(measurement)
|
||||
logMeasurement('Viewport pan sweep', measurement, [
|
||||
'styleRecalcs',
|
||||
'layouts',
|
||||
'taskDurationMs',
|
||||
'heapDeltaBytes',
|
||||
'domNodes'
|
||||
])
|
||||
})
|
||||
test('subgraph DOM widget clipping during node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -271,9 +271,11 @@ test.describe('Workflows sidebar', () => {
|
||||
'.comfyui-workflows-open .close-workflow-button'
|
||||
)
|
||||
await closeButton.click()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow'
|
||||
])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames(), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toEqual(['*Unsaved Workflow'])
|
||||
})
|
||||
|
||||
test('Can close saved workflow with command', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
|
||||
// TODO: Extract allNodeIds accessor into LGraph
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const isNonNegative = (id: number | string) =>
|
||||
typeof id === 'number' && id >= 0
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
isNonNegative(link.origin_id) &&
|
||||
!g._nodes_by_id[link.origin_id] &&
|
||||
`${label}: origin_id ${link.origin_id} not found`,
|
||||
isNonNegative(link.target_id) &&
|
||||
!g._nodes_by_id[link.target_id] &&
|
||||
`${label}: target_id ${link.target_id} not found`
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const isInSubgraph = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.app!.canvas.graph?.isRootGraph === false
|
||||
)
|
||||
|
||||
expect(await isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget DOM position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
const headerBox = await nodeLocator
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
|
||||
// Widget top should be below the header bottom
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
})
|
||||
}
|
||||
)
|
||||