mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-23 07:50:15 +00:00
Compare commits
105 Commits
architectu
...
sno-qa-103
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
161f4bf0b8 | ||
|
|
4310c5238a | ||
|
|
a7d7d39712 | ||
|
|
24ac6f1566 | ||
|
|
f9a5baba1a | ||
|
|
628f64631b | ||
|
|
bc38b2ce13 | ||
|
|
ca8e4d2a29 | ||
|
|
55ce174c5b | ||
|
|
49176271f8 | ||
|
|
50518449fc | ||
|
|
65aa03b20d | ||
|
|
ce59b6a431 | ||
|
|
203e8c8a60 | ||
|
|
b485d22760 | ||
|
|
2d1088f79e | ||
|
|
1fe0f97aa5 | ||
|
|
178ecc6746 | ||
|
|
20f878f929 | ||
|
|
712c386a69 | ||
|
|
8f6fa738a5 | ||
|
|
387862d8b9 | ||
|
|
ff98eb13e4 | ||
|
|
f4f80d179f | ||
|
|
f758e16b72 | ||
|
|
45e309c5f8 | ||
|
|
79df405733 | ||
|
|
27c64e1092 | ||
|
|
a1307ef35c | ||
|
|
11f02a2645 | ||
|
|
b3bcc3ff4c | ||
|
|
49e904918e | ||
|
|
78e5b1e1b3 | ||
|
|
81e3dc72f8 | ||
|
|
936cf83337 | ||
|
|
1d880bf493 | ||
|
|
da3e6cb4cf | ||
|
|
a39e3054cf | ||
|
|
5a6178e924 | ||
|
|
a11e8a67f8 | ||
|
|
6a836b7c25 | ||
|
|
f91f94f71a | ||
|
|
7502528733 | ||
|
|
0656091959 | ||
|
|
6515170d08 | ||
|
|
25cbe56a34 | ||
|
|
120a531ef9 | ||
|
|
389f6ca6b8 | ||
|
|
6298fc3a58 | ||
|
|
83702c2e87 | ||
|
|
c5d207fa9a | ||
|
|
ab6dff02c9 | ||
|
|
7396d39a6a | ||
|
|
99e6681237 | ||
|
|
ae29224874 | ||
|
|
47e5c39ac9 | ||
|
|
28f530d53a | ||
|
|
282754743d | ||
|
|
0a91ec09e8 | ||
|
|
25fd1b2700 | ||
|
|
b9d5ff0f8d | ||
|
|
746d465912 | ||
|
|
e314a18b90 | ||
|
|
78fb9ef27f | ||
|
|
41999c2e0f | ||
|
|
5c243d16be | ||
|
|
774dcd823e | ||
|
|
b500f826fc | ||
|
|
1cd9e171c6 | ||
|
|
5c0bef9b72 | ||
|
|
94e1388495 | ||
|
|
0268e8f977 | ||
|
|
4f1df7c7ce | ||
|
|
3b2fdc786a | ||
|
|
4d1ad4dcf0 | ||
|
|
7ba1aaed53 | ||
|
|
09a3c10d50 | ||
|
|
2f30fbe060 | ||
|
|
85adbe4878 | ||
|
|
0c707b5deb | ||
|
|
4f98518c22 | ||
|
|
ca394b9ff7 | ||
|
|
51878db99d | ||
|
|
d0534003e7 | ||
|
|
3d0cd72465 | ||
|
|
82849df891 | ||
|
|
1c62c0edc3 | ||
|
|
6ea2ce755d | ||
|
|
8fc4480ee2 | ||
|
|
3515a478fd | ||
|
|
11432992d3 | ||
|
|
e619b0143a | ||
|
|
7d4a008f29 | ||
|
|
a3e65140a9 | ||
|
|
f55ab36dd7 | ||
|
|
698a894b42 | ||
|
|
fbd7f404ef | ||
|
|
d633ce19a7 | ||
|
|
9a7b5f88a0 | ||
|
|
be261a8a86 | ||
|
|
b2c31e785f | ||
|
|
475f9ae5f0 | ||
|
|
c329022e15 | ||
|
|
63d0df9ff0 | ||
|
|
c1593054fb |
@@ -1,118 +0,0 @@
|
||||
---
|
||||
name: adr-compliance
|
||||
description: Checks code changes against Architecture Decision Records, with emphasis on ECS (ADR 0008) and command-pattern (ADR 0003) compliance
|
||||
severity-default: medium
|
||||
tools: [Read, Grep, glob]
|
||||
---
|
||||
|
||||
Check that code changes are consistent with the project's Architecture Decision Records in `docs/adr/`.
|
||||
|
||||
## Priority 1: ECS and Command-Pattern Compliance (ADR 0008 + ADR 0003)
|
||||
|
||||
These are the primary architectural guardrails. Every entity/litegraph change must be checked against them.
|
||||
|
||||
### Command Pattern (ADR 0003)
|
||||
|
||||
All entity state mutations MUST be expressible as **serializable, idempotent, deterministic commands**. This is required for CRDT sync, undo/redo, cross-environment portability, and gateway backends.
|
||||
|
||||
Flag:
|
||||
|
||||
- **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside of a store or command. All spatial data flows through `layoutStore` commands.
|
||||
- **Imperative fire-and-forget mutation** — Any new API that mutates entity state as a side effect rather than producing a serializable command object. Systems should produce command batches, not execute mutations directly.
|
||||
- **Void-returning mutation APIs** — New entity mutation functions that return `void` instead of a result type (`{ status: 'applied' | 'rejected' | 'no-op' }`). Commands need error/rejection semantics.
|
||||
- **Auto-incrementing IDs in new entity code** — New entity creation using auto-increment counters without acknowledging the CRDT collision problem. Concurrent environments need globally unique, stable identifiers.
|
||||
|
||||
### ECS Architecture (ADR 0008)
|
||||
|
||||
The graph domain model is migrating to ECS. New code must not make the migration harder.
|
||||
|
||||
Flag:
|
||||
|
||||
- **God-object growth** — New methods/properties added to `LGraphNode` (~4k lines), `LGraphCanvas` (~9k lines), `LGraph` (~3k lines), or `Subgraph`. Extract to systems, stores, or composables instead.
|
||||
- **Mixed data and behavior** — New component-like data structures that contain methods or back-references to parent entities. ECS components are plain data objects.
|
||||
- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes.
|
||||
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
|
||||
Flag:
|
||||
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
Entity API changes affect 40+ custom node repos. Changes to these patterns require an extension migration path.
|
||||
|
||||
Flag when changed without migration guidance:
|
||||
|
||||
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
|
||||
- `onConnectInput` / `onConnectOutput` validation hooks
|
||||
- `onWidgetChanged` handlers
|
||||
- `node.widgets.find(w => w.name === ...)` patterns
|
||||
- `node.serialize` overrides
|
||||
- `graph._version++` direct mutation
|
||||
- `getNodeById` usage patterns
|
||||
|
||||
## Priority 2: General ADR Compliance
|
||||
|
||||
For all other ADRs, iterate through each file in `docs/adr/` and extract the core lesson. Ensure changed code does not contradict accepted ADRs. Flag contradictions with proposed ADRs as directional guidance.
|
||||
|
||||
### How to Apply
|
||||
|
||||
1. Read `docs/adr/README.md` to get the full ADR index
|
||||
2. For each ADR, read the Decision and Consequences sections
|
||||
3. Check the diff against each ADR's constraints
|
||||
4. Only flag ACTUAL violations in changed code, not pre-existing patterns
|
||||
|
||||
### Skip List
|
||||
|
||||
These ADRs can be skipped for most reviews (they cover completed or narrow-scope decisions):
|
||||
|
||||
- **ADR 0004** (Rejected — Fork PrimeVue) — only relevant if someone proposes forking PrimeVue again
|
||||
|
||||
## How to Check
|
||||
|
||||
1. Identify changed files in the entity/litegraph layer: `src/lib/litegraph/`, `src/ecs/`, `src/platform/`, entity-related stores
|
||||
2. For Priority 1 patterns, use targeted searches:
|
||||
|
||||
```
|
||||
# Direct position mutation
|
||||
Grep: pattern="\.pos\s*=" path="src/lib/litegraph"
|
||||
Grep: pattern="\.size\s*=" path="src/lib/litegraph"
|
||||
|
||||
# God object growth (new methods)
|
||||
Grep: pattern="(class LGraphNode|class LGraphCanvas|class LGraph\b)" path="src/lib/litegraph"
|
||||
|
||||
# Version mutation
|
||||
Grep: pattern="_version\+\+" path="src/lib/litegraph"
|
||||
|
||||
# Extension callback changes
|
||||
Grep: pattern="on(ConnectionsChange|Removed|Added|Configure|ConnectInput|ConnectOutput|WidgetChanged)" path="src/lib/litegraph"
|
||||
```
|
||||
|
||||
3. For Priority 2, read `docs/adr/` files and check for contradictions
|
||||
|
||||
## Severity Guidelines
|
||||
|
||||
| Issue | Severity |
|
||||
| -------------------------------------------------------- | -------- |
|
||||
| Imperative mutation API without command-pattern wrapper | high |
|
||||
| New god-object method on LGraphNode/LGraphCanvas/LGraph | high |
|
||||
| Breaking extension callback without migration path | high |
|
||||
| New circular entity dependency | high |
|
||||
| Direct spatial mutation bypassing command pattern | medium |
|
||||
| Mixed data/behavior in component-like structures | medium |
|
||||
| New OOP inheritance pattern for entities | medium |
|
||||
| Contradicts accepted ADR direction | medium |
|
||||
| Contradicts proposed ADR direction without justification | low |
|
||||
|
||||
## Rules
|
||||
|
||||
- Only flag ACTUAL violations in changed code, not pre-existing patterns
|
||||
- If a change explicitly acknowledges an ADR tradeoff in comments or PR description, lower severity
|
||||
- Proposed ADRs carry less weight than accepted ones — flag as directional guidance
|
||||
- Reference the specific ADR number in every finding
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: playwright-e2e
|
||||
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
|
||||
severity-default: medium
|
||||
tools: [Read, Grep]
|
||||
---
|
||||
|
||||
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
|
||||
|
||||
Reference docs (read if you need full context):
|
||||
|
||||
- `browser_tests/README.md` — setup, patterns, screenshot workflow
|
||||
- `browser_tests/AGENTS.md` — directory structure, fixture overview
|
||||
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
|
||||
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
|
||||
|
||||
## Checks
|
||||
|
||||
### Flakiness Risks (Major)
|
||||
|
||||
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
|
||||
|
||||
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
|
||||
|
||||
3. **Keyboard actions without prior focus** — `page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
|
||||
|
||||
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
|
||||
|
||||
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
|
||||
|
||||
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
|
||||
|
||||
7. **Double-click without `{ delay }` option** — `dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
|
||||
|
||||
### Fixture & API Misuse (Medium)
|
||||
|
||||
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
|
||||
- `comfyPage.command.executeCommand()` for menu/command actions
|
||||
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
|
||||
- `comfyPage.canvasOps.resetView()` for view reset
|
||||
- `comfyPage.settings.setSetting()` for settings
|
||||
- Component page objects in `browser_tests/fixtures/components/`
|
||||
|
||||
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
|
||||
|
||||
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
|
||||
|
||||
### Convention Violations (Minor)
|
||||
|
||||
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
|
||||
|
||||
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
|
||||
|
||||
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
|
||||
|
||||
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
|
||||
|
||||
15. **Debug helpers left in committed code** — `debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
|
||||
|
||||
### Test Design (Nitpick)
|
||||
|
||||
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
|
||||
|
||||
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
|
||||
|
||||
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
|
||||
|
||||
## Rules
|
||||
|
||||
- Only review `.spec.ts` files and supporting code in `browser_tests/`
|
||||
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
|
||||
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
|
||||
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
|
||||
- Existing tests that predate conventions are acceptable to modify but not required to fix
|
||||
@@ -1,94 +0,0 @@
|
||||
# ADR Compliance Audit
|
||||
|
||||
Audit the current changes (or a specified PR) for compliance with Architecture Decision Records.
|
||||
|
||||
## Step 1: Gather the Diff
|
||||
|
||||
- If a PR number is provided, run: `gh pr diff $PR_NUMBER`
|
||||
- Otherwise, run: `git diff origin/main...HEAD` (or `git diff --cached` for staged changes)
|
||||
|
||||
## Step 2: Priority 1 — ECS and Command-Pattern Compliance
|
||||
|
||||
Read these documents for context:
|
||||
|
||||
```
|
||||
docs/adr/0003-crdt-based-layout-system.md
|
||||
docs/adr/0008-entity-component-system.md
|
||||
docs/architecture/ecs-target-architecture.md
|
||||
docs/architecture/ecs-migration-plan.md
|
||||
docs/architecture/appendix-critical-analysis.md
|
||||
```
|
||||
|
||||
### Check A: Command Pattern (ADR 0003)
|
||||
|
||||
Every entity state mutation must be a **serializable, idempotent, deterministic command** — replayable, undoable, transmittable over CRDT.
|
||||
|
||||
Flag:
|
||||
|
||||
1. **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside a store/command
|
||||
2. **Imperative fire-and-forget APIs** — Functions that mutate entity state as side effects rather than producing serializable command objects. Systems should produce command batches, not execute mutations directly.
|
||||
3. **Void-returning mutation APIs** — Entity mutations returning `void` instead of `{ status: 'applied' | 'rejected' | 'no-op' }`
|
||||
4. **Auto-increment IDs** — New entity creation via counters without addressing CRDT collision. Concurrent environments need globally unique identifiers.
|
||||
5. **Missing transaction semantics** — Multi-entity operations without atomic grouping (e.g., node removal = 10+ deletes with no rollback on failure)
|
||||
|
||||
### Check B: ECS Architecture (ADR 0008)
|
||||
|
||||
Flag:
|
||||
|
||||
1. **God-object growth** — New methods/properties on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
2. **Mixed data/behavior** — Component-like structures with methods or back-references
|
||||
3. **OOP instance patterns** — New `node.someProperty` or `node.someMethod()` for data that should be a World component
|
||||
4. **OOP inheritance** — New entity subclasses instead of component composition
|
||||
5. **Circular entity deps** — New `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas` circular imports
|
||||
6. **Direct `_version++`** — Mutating private version counter instead of through public API
|
||||
|
||||
### Check C: Extension Ecosystem Impact
|
||||
|
||||
If any of these patterns are changed, flag and require migration guidance:
|
||||
|
||||
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
|
||||
- `onConnectInput` / `onConnectOutput` validation hooks
|
||||
- `onWidgetChanged` handlers
|
||||
- `node.widgets.find(w => w.name === ...)` access patterns
|
||||
- `node.serialize` overrides
|
||||
- `graph._version++` direct mutation
|
||||
|
||||
Reference: 40+ custom node repos depend on these (rgthree-comfy, ComfyUI-Impact-Pack, cg-use-everywhere, etc.)
|
||||
|
||||
## Step 3: Priority 2 — General ADR Compliance
|
||||
|
||||
1. Read `docs/adr/README.md` for the full ADR index
|
||||
2. For each ADR (except skip list), read the Decision section
|
||||
3. Check the diff for contradictions
|
||||
4. Only flag ACTUAL violations in changed code
|
||||
|
||||
**Skip list**: ADR 0004 (Rejected — Fork PrimeVue)
|
||||
|
||||
## Step 4: Generate Report
|
||||
|
||||
```
|
||||
## ADR Compliance Audit Report
|
||||
|
||||
### Summary
|
||||
- Files audited: N
|
||||
- Priority 1 findings: N (command-pattern: N, ECS: N, ecosystem: N)
|
||||
- Priority 2 findings: N
|
||||
|
||||
### Priority 1: Command Pattern & ECS
|
||||
(List each with ADR reference, file, line, description)
|
||||
|
||||
### Priority 1: Extension Ecosystem Impact
|
||||
(List each changed callback/API with affected custom node repos)
|
||||
|
||||
### Priority 2: General ADR Compliance
|
||||
(List each with ADR reference, file, line, description)
|
||||
|
||||
### Compliant Patterns
|
||||
(Note changes that positively align with ADR direction)
|
||||
```
|
||||
|
||||
## Severity
|
||||
|
||||
- **Must fix**: Contradicts accepted ADR, or introduces imperative mutation API without command-pattern wrapper, or breaks extension callback without migration path
|
||||
- **Should discuss**: Contradicts proposed ADR direction — either align or propose ADR amendment
|
||||
- **Note**: Surfaces open architectural question not yet addressed by ADRs
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
name: adding-deprecation-warnings
|
||||
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
|
||||
---
|
||||
|
||||
# Adding Deprecation Warnings
|
||||
|
||||
Adds backward-compatible deprecation warnings for renamed or removed
|
||||
properties using the `defineDeprecatedProperty` helper in
|
||||
`src/lib/litegraph/src/utils/feedback.ts`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A property or API has been renamed and custom nodes still use the old name
|
||||
- A property is being removed but needs a grace period
|
||||
- Backward compatibility must be preserved while nudging adoption
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Search the Custom Node Ecosystem
|
||||
|
||||
Before implementing, assess impact by searching for usage of the
|
||||
deprecated property across ComfyUI custom nodes:
|
||||
|
||||
```text
|
||||
Use the comfy_codesearch tool to search for the old property name.
|
||||
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
|
||||
```
|
||||
|
||||
Document the usage patterns found (property access, truthiness checks,
|
||||
caching to local vars, style mutation, etc.) — these all must continue
|
||||
working.
|
||||
|
||||
### 2. Apply the Deprecation
|
||||
|
||||
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
/** @deprecated Use {@link obj.newProp} instead. */
|
||||
defineDeprecatedProperty(
|
||||
obj,
|
||||
'oldProp',
|
||||
'newProp',
|
||||
'obj.oldProp is deprecated. Use obj.newProp instead.'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Checklist
|
||||
|
||||
- [ ] Ecosystem search completed — all usage patterns are compatible
|
||||
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
|
||||
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
|
||||
- [ ] Warning message names both old and new property clearly
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm lint` passes
|
||||
|
||||
### 4. PR Comment
|
||||
|
||||
Add a PR comment summarizing the ecosystem search results: which repos
|
||||
use the deprecated property, what access patterns were found, and
|
||||
confirmation that all patterns are compatible with the ODP getter/setter.
|
||||
|
||||
## How `defineDeprecatedProperty` Works
|
||||
|
||||
- Creates an `Object.defineProperty` getter/setter on the target object
|
||||
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
|
||||
- Both log via `warnDeprecated`, which deduplicates (once per unique
|
||||
message per session via a `Set`)
|
||||
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
|
||||
/ `JSON.stringify`
|
||||
- `configurable: true` allows further redefinition if needed
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
|
||||
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
|
||||
once then the cached ref is used directly
|
||||
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
|
||||
works, getter returns the real object
|
||||
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
|
||||
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
|
||||
once per session regardless of call count
|
||||
@@ -18,20 +18,12 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## CI Safety Rules
|
||||
|
||||
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
|
||||
|
||||
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
|
||||
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
|
||||
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
|
||||
| Item | Value |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
@@ -116,15 +108,11 @@ git fetch origin TARGET_BRANCH
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
### Never Admin-Merge Without CI
|
||||
|
||||
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
|
||||
@@ -19,44 +19,23 @@ done
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
# Check which got auto-PRs
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
|
||||
|
||||
## Step 2: Wait for CI & Merge Clean Auto-PRs
|
||||
|
||||
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
|
||||
```bash
|
||||
# Wait for CI to complete (~45 minutes for full suite)
|
||||
sleep 2700
|
||||
|
||||
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
|
||||
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
|
||||
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
|
||||
|
||||
# For PRs still open, check CI status
|
||||
for pr in $STILL_OPEN_PRS; do
|
||||
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
|
||||
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
|
||||
if [ "$CI_FAILED" != "0" ]; then
|
||||
# CI failed — collect details for triage
|
||||
echo "PR #$pr — CI FAILED:"
|
||||
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
|
||||
elif [ "$CI_PENDING" != "0" ]; then
|
||||
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
|
||||
else
|
||||
# All checks passed but didn't auto-merge (race condition or label issue)
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
fi
|
||||
for pr in $AUTO_PRS; do
|
||||
# Check size
|
||||
gh pr view $pr --json title,additions,deletions,changedFiles \
|
||||
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
|
||||
# Admin merge
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
@@ -84,13 +63,6 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
|
||||
# Wait for CI before merging — NEVER admin-merge without CI passing
|
||||
echo "Waiting for CI on PR #$NEW_PR..."
|
||||
gh pr checks $NEW_PR --watch --fail-fast || {
|
||||
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
|
||||
continue
|
||||
}
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
@@ -110,7 +82,7 @@ After completing all PRs in a wave for a target branch:
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
@@ -160,8 +132,7 @@ git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
@@ -175,31 +146,5 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
When CI fails on a backport PR, present failures to the user using this template:
|
||||
|
||||
```markdown
|
||||
### PR #XXXX — CI Failed
|
||||
|
||||
- **Failing check:** test / lint / typecheck
|
||||
- **Error:** (summary of the failure message)
|
||||
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
|
||||
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
|
||||
```
|
||||
|
||||
Common failure categories:
|
||||
|
||||
| Category | Example | Resolution |
|
||||
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
|
||||
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
|
||||
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
|
||||
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | CI Status | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
@@ -19,7 +19,6 @@ Track verification results per wave:
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Unit tests: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
@@ -42,11 +41,6 @@ Track verification results per wave:
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## CI Failure Report
|
||||
|
||||
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
|
||||
| --- | ------ | ------------- | ------------- | ----- | ---------- |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
|
||||
278
.claude/skills/comfy-qa/REPRODUCE.md
Normal file
278
.claude/skills/comfy-qa/REPRODUCE.md
Normal file
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: reproduce-issue
|
||||
description: 'Reproduce a GitHub issue by researching prerequisites, setting up the environment (custom nodes, workflows, settings), and interactively exploring ComfyUI via playwright-cli until the bug is confirmed. Then records a clean demo video.'
|
||||
---
|
||||
|
||||
# Issue Reproduction Skill
|
||||
|
||||
Reproduce a reported GitHub issue against a running ComfyUI instance. This skill uses an interactive, agent-driven approach — not a static script. You will research, explore, retry, and adapt until the bug is reproduced, then record a clean demo.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two videos are produced:
|
||||
|
||||
1. **Research video** — the full exploration session: installing deps, trying things, failing, retrying, figuring out the bug. Valuable for debugging context.
|
||||
2. **Reproduce video** — a clean, minimal recording of just the reproduction steps. This is the demo you'd attach to the issue.
|
||||
|
||||
```
|
||||
Phase 1: Research → Read issue, understand prerequisites
|
||||
Phase 2: Environment → Install custom nodes, load workflows, configure settings
|
||||
Phase 3: Explore → [VIDEO 1: research] Interactively try to reproduce (retries OK)
|
||||
Phase 4: Record → [VIDEO 2: reproduce] Clean recording of just the minimal repro steps
|
||||
Phase 5: Report → Generate a structured reproduction report
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ComfyUI server running (ask user for URL, default: `http://127.0.0.1:8188`)
|
||||
- `playwright-cli` installed: `npm install -g @playwright/cli@latest`
|
||||
- `gh` CLI (authenticated, for reading issues)
|
||||
- ComfyUI backend with Python environment (for installing custom nodes)
|
||||
|
||||
## Phase 1: Research the Issue
|
||||
|
||||
1. Fetch the issue details:
|
||||
|
||||
```bash
|
||||
gh issue view <number> --repo Comfy-Org/ComfyUI_frontend --json title,body,comments
|
||||
```
|
||||
|
||||
2. Extract from the issue body:
|
||||
- **Reproduction steps** (the exact sequence)
|
||||
- **Prerequisites**: specific workflows, custom nodes, settings, models
|
||||
- **Environment**: OS, browser, ComfyUI version
|
||||
- **Media**: screenshots or videos showing the bug
|
||||
|
||||
3. Search the codebase for related code:
|
||||
- Find the feature/component mentioned in the issue
|
||||
- Understand how it works currently
|
||||
- Identify what state the UI needs to be in
|
||||
|
||||
## Phase 2: Environment Setup
|
||||
|
||||
Set up everything the issue requires BEFORE attempting reproduction.
|
||||
|
||||
### Custom Nodes
|
||||
|
||||
If the issue mentions custom nodes:
|
||||
|
||||
```bash
|
||||
# Find the custom node repo
|
||||
# Clone into ComfyUI's custom_nodes directory
|
||||
cd <comfyui_path>/custom_nodes
|
||||
git clone <custom_node_repo_url>
|
||||
|
||||
# Install dependencies if needed
|
||||
cd <custom_node_name>
|
||||
pip install -r requirements.txt 2>/dev/null || true
|
||||
|
||||
# Restart ComfyUI server to load the new nodes
|
||||
```
|
||||
|
||||
### Workflows
|
||||
|
||||
If the issue references a specific workflow:
|
||||
|
||||
```bash
|
||||
# Download workflow JSON if a URL is provided
|
||||
curl -L "<workflow_url>" -o /tmp/test-workflow.json
|
||||
|
||||
# Load it via the API
|
||||
curl -X POST http://127.0.0.1:8188/api/workflow \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/test-workflow.json
|
||||
```
|
||||
|
||||
Or load via playwright-cli:
|
||||
|
||||
```bash
|
||||
playwright-cli goto "http://127.0.0.1:8188"
|
||||
# Drag-and-drop or use File > Open to load the workflow
|
||||
```
|
||||
|
||||
### Settings
|
||||
|
||||
If the issue requires specific settings:
|
||||
|
||||
```bash
|
||||
# Use playwright-cli to open settings and change them
|
||||
playwright-cli press "Control+,"
|
||||
playwright-cli snapshot
|
||||
# Find and modify the relevant setting
|
||||
```
|
||||
|
||||
## Phase 3: Interactive Exploration — Research Video
|
||||
|
||||
Start recording the **research video** (Video 1). This captures the full exploration — mistakes, retries, dead ends — all valuable context.
|
||||
|
||||
```bash
|
||||
# Open browser and start video recording
|
||||
playwright-cli open "http://127.0.0.1:8188"
|
||||
playwright-cli video-start
|
||||
|
||||
# Take a snapshot to see current state
|
||||
playwright-cli snapshot
|
||||
|
||||
# Interact based on what you see
|
||||
playwright-cli click <ref>
|
||||
playwright-cli fill <ref> "text"
|
||||
playwright-cli press "Control+s"
|
||||
|
||||
# Check results
|
||||
playwright-cli snapshot
|
||||
playwright-cli screenshot --filename=/tmp/qa/research-step-1.png
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
- **Observe before acting**: Always `snapshot` before interacting
|
||||
- **Retry and adapt**: If a step fails, try a different approach
|
||||
- **Document what works**: Keep notes on which steps trigger the bug
|
||||
- **Don't give up**: Try multiple approaches if the first doesn't work
|
||||
- **Establish prerequisites**: Many bugs require specific UI state:
|
||||
- Save a workflow first (File > Save)
|
||||
- Make changes to dirty the workflow
|
||||
- Open multiple tabs
|
||||
- Add specific node types
|
||||
- Change settings
|
||||
- Resize the window
|
||||
|
||||
### Common ComfyUI Interactions via playwright-cli
|
||||
|
||||
| Action | Command |
|
||||
| ------------------- | -------------------------------------------------------------- |
|
||||
| Open hamburger menu | `playwright-cli click` on the C logo button |
|
||||
| Navigate menu | `playwright-cli hover <ref>` then `playwright-cli click <ref>` |
|
||||
| Add node | Double-click canvas → type node name → select from results |
|
||||
| Connect nodes | Drag from output slot to input slot |
|
||||
| Save workflow | `playwright-cli press "Control+s"` |
|
||||
| Save As | Menu > File > Save As |
|
||||
| Select node | Click on the node |
|
||||
| Delete node | Select → `playwright-cli press "Delete"` |
|
||||
| Right-click menu | `playwright-cli click <ref> --button right` |
|
||||
| Keyboard shortcut | `playwright-cli press "Control+z"` |
|
||||
|
||||
## Phase 4: Record Clean Demo — Reproduce Video (max 5 minutes)
|
||||
|
||||
Once the bug is confirmed, **stop the research video** and **close the research browser**:
|
||||
|
||||
```bash
|
||||
playwright-cli video-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
Now start a **fresh browser session** for the clean reproduce video (Video 2).
|
||||
|
||||
**IMPORTANT constraints:**
|
||||
|
||||
- **Max 5 minutes** — the reproduce video must be short and focused
|
||||
- **No environment setup** — server, user, custom nodes are already set up from Phase 3. Just log in and go.
|
||||
- **No exploration** — you already know the exact steps. Execute them quickly and precisely.
|
||||
- **Start video recording immediately**, execute steps, stop. Don't leave the recording running while thinking.
|
||||
|
||||
1. **Open browser and start recording**:
|
||||
|
||||
```bash
|
||||
playwright-cli open "http://127.0.0.1:8188"
|
||||
playwright-cli video-start
|
||||
```
|
||||
|
||||
2. **Execute only the minimal reproduction steps** — no exploration, no mistakes. Just the clean sequence that demonstrates the bug. You already know exactly what works from Phase 3.
|
||||
|
||||
3. **Take key screenshots** at critical moments:
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot --filename=/tmp/qa/before-bug.png
|
||||
# ... trigger the bug ...
|
||||
playwright-cli screenshot --filename=/tmp/qa/bug-visible.png
|
||||
```
|
||||
|
||||
4. **Stop recording and close** immediately after the bug is demonstrated:
|
||||
```bash
|
||||
playwright-cli video-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Phase 5: Generate Report
|
||||
|
||||
Create a reproduction report at `tmp/qa/reproduce-report.md`:
|
||||
|
||||
```markdown
|
||||
# Issue Reproduction Report
|
||||
|
||||
- **Issue**: <issue_url>
|
||||
- **Title**: <issue_title>
|
||||
- **Date**: <today>
|
||||
- **Status**: Reproduced / Not Reproduced / Partially Reproduced
|
||||
|
||||
## Environment
|
||||
|
||||
- ComfyUI Server: <url>
|
||||
- OS: <os>
|
||||
- Custom Nodes Installed: <list or "none">
|
||||
- Settings Changed: <list or "none">
|
||||
|
||||
## Prerequisites
|
||||
|
||||
List everything that had to be set up before the bug could be triggered:
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
Minimal steps to reproduce (the clean sequence):
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<from the issue>
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<what actually happened>
|
||||
|
||||
## Evidence
|
||||
|
||||
- Research video: `research-video/video.webm` (full exploration session)
|
||||
- Reproduce video: `reproduce-video/video.webm` (clean minimal repro)
|
||||
- Screenshots: `before-bug.png`, `bug-visible.png`
|
||||
|
||||
## Root Cause Analysis (if identified)
|
||||
|
||||
<code pointers, hypothesis about what's going wrong>
|
||||
|
||||
## Notes
|
||||
|
||||
<any additional observations, workarounds discovered, related issues>
|
||||
```
|
||||
|
||||
## Handling Failures
|
||||
|
||||
If the bug **cannot be reproduced**:
|
||||
|
||||
1. Document what you tried and why it didn't work
|
||||
2. Check if the issue was already fixed (search git log for related commits)
|
||||
3. Check if it's environment-specific (OS, browser, specific version)
|
||||
4. Set report status to "Not Reproduced" with detailed notes
|
||||
5. The report is still valuable — it saves others from repeating the same investigation
|
||||
|
||||
## CI Integration
|
||||
|
||||
In CI, this skill runs as a Claude Code agent with:
|
||||
|
||||
- `ANTHROPIC_API_KEY` for Claude
|
||||
- `GEMINI_API_KEY` for initial issue analysis (optional)
|
||||
- ComfyUI server pre-started in the container
|
||||
- `playwright-cli` pre-installed
|
||||
|
||||
The CI workflow:
|
||||
|
||||
1. Gemini generates a reproduce guide (markdown) from the issue
|
||||
2. Claude agent receives the guide and runs this skill
|
||||
3. Claude explores interactively, installs dependencies, retries
|
||||
4. Claude records a clean demo once reproduced
|
||||
5. Video and report are uploaded as artifacts
|
||||
361
.claude/skills/comfy-qa/SKILL.md
Normal file
361
.claude/skills/comfy-qa/SKILL.md
Normal file
@@ -0,0 +1,361 @@
|
||||
---
|
||||
name: comfy-qa
|
||||
description: 'Comprehensive QA of ComfyUI frontend. Navigates all routes, tests all interactive features using playwright-cli, generates a report, and submits a draft PR. Works in CI and local environments, cross-platform.'
|
||||
---
|
||||
|
||||
# ComfyUI Frontend QA Skill
|
||||
|
||||
Perform comprehensive quality assurance of the ComfyUI frontend application by navigating all routes, clicking interactive elements, and testing features. Generate a structured report and submit it as a draft PR.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- `pnpm` package manager
|
||||
- `gh` CLI (authenticated)
|
||||
- `playwright-cli` (browser automation): `npm install -g @playwright/cli@latest`
|
||||
|
||||
## Step 1: Environment Detection & Setup
|
||||
|
||||
Detect the runtime environment and ensure the app is accessible.
|
||||
|
||||
### CI Environment
|
||||
|
||||
If `CI=true` is set:
|
||||
|
||||
1. The ComfyUI backend is pre-configured in the CI container (`ghcr.io/comfy-org/comfyui-ci-container`)
|
||||
2. Frontend dist is already built and served by the backend
|
||||
3. Server runs at `http://127.0.0.1:8188`
|
||||
4. Skip user prompts — run fully automated
|
||||
|
||||
### Local Environment
|
||||
|
||||
If `CI` is not set:
|
||||
|
||||
1. **Ask the user**: "Is a ComfyUI server already running? If so, what URL? (default: http://127.0.0.1:8188)"
|
||||
- If yes: use the provided URL
|
||||
- If no: offer to start one:
|
||||
|
||||
```bash
|
||||
# Option A: Use existing ComfyUI installation
|
||||
# Ask for the path to ComfyUI, then:
|
||||
cd <comfyui_path>
|
||||
python main.py --cpu --multi-user --front-end-root <frontend_dist_path> &
|
||||
|
||||
# Option B: Build frontend and use preview server (no backend features)
|
||||
pnpm build && pnpm preview &
|
||||
```
|
||||
|
||||
2. Wait for server readiness by polling the URL (retry with 2s intervals, 60s timeout)
|
||||
|
||||
### Browser Automation Setup
|
||||
|
||||
Use **playwright-cli** for browser interaction via Bash commands:
|
||||
|
||||
```bash
|
||||
playwright-cli open http://127.0.0.1:8188 # open browser and navigate
|
||||
playwright-cli snapshot # capture snapshot with element refs
|
||||
playwright-cli click e1 # click by element ref from snapshot
|
||||
playwright-cli press Tab # keyboard shortcuts
|
||||
playwright-cli screenshot --filename=f.png # save screenshot
|
||||
```
|
||||
|
||||
playwright-cli is headless by default (CI-friendly). Each command outputs the current page snapshot with element references (`e1`, `e2`, …) that you use for subsequent `click`, `fill`, `hover` commands. Always run `snapshot` before interacting to get fresh refs.
|
||||
|
||||
For local dev servers behind proxies, adjust the URL accordingly (e.g., `https://[port].stukivx.xyz` pattern if configured).
|
||||
|
||||
## Step 2: QA Test Plan
|
||||
|
||||
Navigate to the application URL and systematically test each area below. For each test, record:
|
||||
|
||||
- **Status**: pass / fail / skip (with reason)
|
||||
- **Notes**: any issues, unexpected behavior, or visual glitches
|
||||
- **Screenshots**: take screenshots of failures or notable states
|
||||
|
||||
### 2.1 Application Load & Routes
|
||||
|
||||
| Test | Steps |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| Root route loads | Navigate to `/` — GraphView should render with canvas |
|
||||
| User select route | Navigate to `/user-select` — user selection UI should appear |
|
||||
| Default redirect | If multi-user mode, `/` redirects to `/user-select` first |
|
||||
| 404 handling | Navigate to `/nonexistent` — should handle gracefully |
|
||||
|
||||
### 2.2 Canvas & Graph View
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------------- | -------------------------------------------------------------- |
|
||||
| Canvas renders | The LiteGraph canvas is visible and interactive |
|
||||
| Pan canvas | Click and drag on empty canvas area |
|
||||
| Zoom in/out | Use scroll wheel or Alt+=/Alt+- |
|
||||
| Fit view | Press `.` key — canvas fits to content |
|
||||
| Add node via double-click | Double-click canvas to open search, type "KSampler", select it |
|
||||
| Add node via search | Open search box, find and add a node |
|
||||
| Delete node | Select a node, press Delete key |
|
||||
| Connect nodes | Drag from output slot to input slot |
|
||||
| Disconnect nodes | Right-click a link and remove, or drag from connected slot |
|
||||
| Multi-select | Shift+click or drag-select multiple nodes |
|
||||
| Copy/Paste | Select nodes, Ctrl+C then Ctrl+V |
|
||||
| Undo/Redo | Make changes, Ctrl+Z to undo, Ctrl+Y to redo |
|
||||
| Node context menu | Right-click a node — menu appears with all expected options |
|
||||
| Canvas context menu | Right-click empty canvas — menu appears |
|
||||
|
||||
### 2.3 Node Operations
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------- | ---------------------------------------------------------- |
|
||||
| Bypass node | Select node, Ctrl+B — node shows bypass state |
|
||||
| Mute node | Select node, Ctrl+M — node shows muted state |
|
||||
| Collapse node | Select node, Alt+C — node collapses |
|
||||
| Pin node | Select node, press P — node becomes pinned |
|
||||
| Rename node | Double-click node title — edit mode activates |
|
||||
| Node color | Right-click > Color — color picker works |
|
||||
| Group nodes | Select multiple nodes, Ctrl+G — group created |
|
||||
| Ungroup | Right-click group > Ungroup |
|
||||
| Widget interactions | Toggle checkboxes, adjust sliders, type in text fields |
|
||||
| Combo widget | Click dropdown widgets — options appear and are selectable |
|
||||
|
||||
### 2.4 Sidebar Tabs
|
||||
|
||||
| Test | Steps |
|
||||
| ---------------------- | ------------------------------------------------------ |
|
||||
| Workflows tab | Press W — workflows sidebar opens with saved workflows |
|
||||
| Node Library tab | Press N — node library opens with categories |
|
||||
| Model Library tab | Press M — model library opens |
|
||||
| Assets tab | Press A — assets browser opens |
|
||||
| Tab toggle | Press same key again — sidebar closes |
|
||||
| Search in sidebar | Type in search box — results filter |
|
||||
| Drag node from library | Drag a node from library onto canvas |
|
||||
|
||||
### 2.5 Topbar & Workflow Tabs
|
||||
|
||||
| Test | Steps |
|
||||
| -------------------- | ------------------------------------------------------ |
|
||||
| Workflow tab display | Current workflow name shown in tab bar |
|
||||
| New workflow | Ctrl+N — new blank workflow created |
|
||||
| Rename workflow | Double-click workflow tab |
|
||||
| Tab context menu | Right-click workflow tab — menu with Close/Rename/etc. |
|
||||
| Multiple tabs | Open multiple workflows, switch between them |
|
||||
| Queue button | Click Queue/Run button — prompt queues |
|
||||
| Batch count | Click batch count editor, change value |
|
||||
| Menu hamburger | Click hamburger menu — options appear |
|
||||
|
||||
### 2.6 Settings Dialog
|
||||
|
||||
| Test | Steps |
|
||||
| ---------------- | ---------------------------------------------------- |
|
||||
| Open settings | Press Ctrl+, or click settings button |
|
||||
| Settings tabs | Navigate through all setting categories |
|
||||
| Change a setting | Toggle a boolean setting — it persists after closing |
|
||||
| Search settings | Type in settings search box — results filter |
|
||||
| Keybindings tab | Navigate to keybindings panel |
|
||||
| About tab | Navigate to about panel — version info shown |
|
||||
| Close settings | Press Escape or click close button |
|
||||
|
||||
### 2.7 Bottom Panel
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------- | -------------------------------------- |
|
||||
| Toggle panel | Press Ctrl+` — bottom panel opens |
|
||||
| Logs tab | Logs/terminal tab shows server output |
|
||||
| Shortcuts tab | Shortcuts reference is displayed |
|
||||
| Keybindings display | Press Ctrl+Shift+K — keybindings panel |
|
||||
|
||||
### 2.8 Execution & Queue
|
||||
|
||||
| Test | Steps |
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| Queue prompt | Load default workflow, click Queue — execution starts |
|
||||
| Queue progress | Progress indicator shows during execution |
|
||||
| Interrupt | Press Ctrl+Alt+Enter during execution — interrupts |
|
||||
| Job history | Open job history sidebar — past executions listed |
|
||||
| Clear history | Clear execution history via menu |
|
||||
|
||||
### 2.9 Workflow File Operations
|
||||
|
||||
| Test | Steps |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| Save workflow | Ctrl+S — workflow saves (check for prompt if new) |
|
||||
| Open workflow | Ctrl+O — file picker or workflow browser opens |
|
||||
| Export JSON | Menu > Export — workflow JSON downloads |
|
||||
| Import workflow | Drag a .json workflow file onto canvas |
|
||||
| Load default | Menu > Load Default — default workflow loads |
|
||||
| Clear workflow | Menu > Clear — canvas clears (after confirmation) |
|
||||
|
||||
### 2.10 Advanced Features
|
||||
|
||||
| Test | Steps |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| Minimap | Alt+M — minimap toggle |
|
||||
| Focus mode | Toggle focus mode |
|
||||
| Canvas lock | Press H to lock, V to unlock |
|
||||
| Link visibility | Ctrl+Shift+L — toggle links |
|
||||
| Subgraph | Select nodes > Ctrl+Shift+E — convert to subgraph |
|
||||
|
||||
### 2.11 Error Handling
|
||||
|
||||
| Test | Steps |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| Missing nodes dialog | Load workflow with non-existent node types |
|
||||
| Missing models dialog | Trigger missing model warning |
|
||||
| Network error | Disconnect backend, verify graceful handling |
|
||||
| Invalid workflow | Try loading malformed JSON |
|
||||
|
||||
### 2.12 Responsive & Accessibility
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| Window resize | Resize browser window — layout adapts |
|
||||
| Keyboard navigation | Tab through interactive elements |
|
||||
| Sidebar resize | Drag sidebar edge to resize |
|
||||
|
||||
## Step 3: Generate Report
|
||||
|
||||
After completing all tests, generate a markdown report file.
|
||||
|
||||
### Report Location
|
||||
|
||||
```
|
||||
docs/qa/YYYY-MM-DD-NNN-report.md
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `YYYY-MM-DD` is today's date
|
||||
- `NNN` is a zero-padded increment index (001, 002, etc.)
|
||||
|
||||
To determine the increment, check existing files:
|
||||
|
||||
```bash
|
||||
ls docs/qa/ | grep "$(date +%Y-%m-%d)" | wc -l
|
||||
```
|
||||
|
||||
### Report Template
|
||||
|
||||
```markdown
|
||||
# QA Report: ComfyUI Frontend
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Environment**: CI / Local (OS, Browser)
|
||||
**Frontend Version**: (git sha or version)
|
||||
**Agent**: Claude / Codex / Other
|
||||
**Server URL**: http://...
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Pass | Fail | Skip | Total |
|
||||
| --------------- | ---- | ---- | ---- | ----- |
|
||||
| Routes & Load | | | | |
|
||||
| Canvas | | | | |
|
||||
| Node Operations | | | | |
|
||||
| Sidebar | | | | |
|
||||
| Topbar | | | | |
|
||||
| Settings | | | | |
|
||||
| Bottom Panel | | | | |
|
||||
| Execution | | | | |
|
||||
| File Operations | | | | |
|
||||
| Advanced | | | | |
|
||||
| Error Handling | | | | |
|
||||
| Responsive | | | | |
|
||||
| **Total** | | | | |
|
||||
|
||||
## Results
|
||||
|
||||
### Routes & Load
|
||||
|
||||
- [x] Root route loads — pass
|
||||
- [ ] ...
|
||||
|
||||
### Canvas & Graph View
|
||||
|
||||
- [x] Canvas renders — pass
|
||||
- [ ] ...
|
||||
|
||||
(repeat for each category)
|
||||
|
||||
## Issues Found
|
||||
|
||||
### Issue 1: [Title]
|
||||
|
||||
- **Severity**: critical / major / minor / cosmetic
|
||||
- **Steps to reproduce**: ...
|
||||
- **Expected**: ...
|
||||
- **Actual**: ...
|
||||
- **Screenshot**: (if available)
|
||||
|
||||
## Notes
|
||||
|
||||
Any additional observations, performance notes, or suggestions.
|
||||
```
|
||||
|
||||
## Step 4: Commit and Push Report
|
||||
|
||||
### In CI (when `CI=true`)
|
||||
|
||||
Save the report directly to `$QA_ARTIFACTS` (the CI workflow uploads this as
|
||||
an artifact and posts results as a PR comment). Do **not** commit, push, or
|
||||
create a new PR.
|
||||
|
||||
### Local / interactive use
|
||||
|
||||
When running locally, create a draft PR after committing:
|
||||
|
||||
```bash
|
||||
# Ensure on a feature branch
|
||||
BRANCH_NAME="qa/$(date +%Y-%m-%d)-$(git rev-parse --short HEAD)"
|
||||
git checkout -b "$BRANCH_NAME" 2>/dev/null || git checkout "$BRANCH_NAME"
|
||||
|
||||
git add docs/qa/
|
||||
git commit -m "docs: add QA report $(date +%Y-%m-%d)
|
||||
|
||||
Automated QA report covering all frontend routes and features."
|
||||
git push -u origin "$BRANCH_NAME"
|
||||
|
||||
# Create draft PR assigned to comfy-pr-bot
|
||||
gh pr create \
|
||||
--draft \
|
||||
--title "QA Report: $(date +%Y-%m-%d)" \
|
||||
--body "## QA Report
|
||||
|
||||
Automated frontend QA run covering all routes and interactive features.
|
||||
|
||||
See \`docs/qa/\` for the full report.
|
||||
|
||||
/cc @comfy-pr-bot" \
|
||||
--assignee comfy-pr-bot
|
||||
```
|
||||
|
||||
## Execution Notes
|
||||
|
||||
### Cross-Platform Considerations
|
||||
|
||||
- **Windows**: Use `pwsh` or `cmd` equivalents for shell commands. `gh` CLI works on all platforms.
|
||||
- **macOS**: Keyboard shortcuts use Cmd instead of Ctrl in the actual app, but Playwright sends OS-appropriate keys.
|
||||
- **Linux**: Primary CI platform. Screenshot baselines are Linux-only.
|
||||
|
||||
### Agent Compatibility
|
||||
|
||||
This skill uses **playwright-cli** (`@playwright/cli`) — a token-efficient CLI designed for coding agents. Install it once with `npm install -g @playwright/cli@latest`, then use `Bash` to run commands.
|
||||
|
||||
The key operations and their playwright-cli equivalents:
|
||||
|
||||
| Action | Command |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| Navigate to URL | `playwright-cli goto <url>` |
|
||||
| Get element refs | `playwright-cli snapshot` |
|
||||
| Click element | `playwright-cli click <ref>` |
|
||||
| Type text | `playwright-cli fill <ref> <text>` |
|
||||
| Press shortcut | `playwright-cli press <key>` |
|
||||
| Take screenshot | `playwright-cli screenshot --filename=f` |
|
||||
| Hover element | `playwright-cli hover <ref>` |
|
||||
| Select dropdown | `playwright-cli select <ref> <value>` |
|
||||
|
||||
Snapshots return element references (`e1`, `e2`, …). Always run `snapshot` after navigation or major interactions to refresh refs before acting.
|
||||
|
||||
### Tips for Reliable QA
|
||||
|
||||
1. **Wait for page stability** before interacting — check that elements are visible and enabled
|
||||
2. **Take a snapshot after each major navigation** to verify state
|
||||
3. **Don't use fixed timeouts** — poll for expected conditions
|
||||
4. **Record the full page snapshot** at the start for baseline comparison
|
||||
5. **If a test fails**, document it and continue — don't abort the entire QA run
|
||||
6. **Group related tests** — complete one category before moving to the next
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: contain-audit
|
||||
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
|
||||
---
|
||||
|
||||
# CSS Containment Audit
|
||||
|
||||
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Loads a large workflow (245 nodes) in a real browser
|
||||
2. Walks the DOM tree and scores every element as a containment candidate
|
||||
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
|
||||
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
|
||||
5. Takes before/after screenshots to detect visual breakage
|
||||
6. Generates a ranked report with actionable recommendations
|
||||
|
||||
## When to Use
|
||||
|
||||
- After adding new Vue components to the node rendering pipeline
|
||||
- When investigating rendering performance on large workflows
|
||||
- Before and after refactoring node DOM structure
|
||||
- As part of periodic performance audits
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Start the dev server first
|
||||
pnpm dev &
|
||||
|
||||
# Run the audit (uses the @audit tag, not included in normal CI runs)
|
||||
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
|
||||
|
||||
# View the HTML report
|
||||
pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
The audit outputs a table to the console:
|
||||
|
||||
```text
|
||||
CSS Containment Audit Results
|
||||
=======================================================
|
||||
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
|
||||
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
|
||||
2 | .node-body | 12 | 48 | -8% | -3% | OK
|
||||
3 | .node-header | 4 | 16 | +1% | 0% | OK
|
||||
```
|
||||
|
||||
- **Subtree**: Number of descendant elements (higher = more to skip)
|
||||
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
|
||||
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
|
||||
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
|
||||
|
||||
## Candidate Scoring
|
||||
|
||||
An element is a good containment candidate when:
|
||||
|
||||
1. **Large subtree** -- many descendants that the browser can skip recalculating
|
||||
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
|
||||
3. **No existing containment** -- `contain` is not already applied
|
||||
4. **Not a leaf** -- has at least a few child elements
|
||||
|
||||
Elements that should NOT get containment:
|
||||
|
||||
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
|
||||
- Elements whose height is determined by content and affects sibling layout
|
||||
- Very small subtrees (overhead of containment context outweighs benefit)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
|
||||
- Performance measurements have natural variance; run multiple times for confidence
|
||||
- Only tests idle and pan scenarios; widget interactions may differ
|
||||
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
|
||||
|
||||
## Example PR
|
||||
|
||||
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
|
||||
|
||||
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
|
||||
|
||||
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
|
||||
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
|
||||
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
|
||||
|
||||
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf tests | `browser_tests/tests/performance.spec.ts` |
|
||||
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
|
||||
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |
|
||||
@@ -28,21 +28,3 @@ reviews:
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
|
||||
|
||||
This check applies ONLY when the PR modifies files under `src/lib/litegraph/`, `src/ecs/`, or files related to graph entities (nodes, links, widgets, slots, reroutes, groups, subgraphs).
|
||||
|
||||
If none of those paths appear in the changed files, pass immediately.
|
||||
|
||||
When applicable, check for:
|
||||
1. **Command pattern (ADR 0003)**: Entity state mutations must be serializable, idempotent, deterministic commands — not imperative fire-and-forget side effects. Flag direct spatial mutation (`node.pos =`, `node.size =`, `group.pos =`) outside of a store or command, and any new void-returning mutation API that should produce a command object.
|
||||
2. **God-object growth (ADR 0008)**: New methods/properties added to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` that add responsibilities rather than extracting/migrating existing ones.
|
||||
3. **ECS data/behavior separation (ADR 0008)**: Component-like data structures that contain methods or back-references to parent entities. ECS components must be plain data. New OOP instance patterns (`node.someProperty`, `node.someMethod()`) for data that should be a World component.
|
||||
4. **Extension ecosystem (ADR 0008)**: Changes to extension-facing callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure`, `onConnectInput/Output`, `onWidgetChanged`), `node.widgets` access, `node.serialize` overrides, or `graph._version++` without migration guidance. These affect 40+ custom node repos.
|
||||
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
11
.github/actions/setup-comfyui-server/action.yaml
vendored
11
.github/actions/setup-comfyui-server/action.yaml
vendored
@@ -44,12 +44,17 @@ runs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
|
||||
- name: Start ComfyUI server
|
||||
if: ${{ inputs.launch_server == 'true' }}
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
env:
|
||||
EXTRA_SERVER_PARAMS: ${{ inputs.extra_server_params }}
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist ${{ inputs.extra_server_params }} &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
python main.py --cpu --multi-user --front-end-root ../dist $EXTRA_SERVER_PARAMS &
|
||||
for i in $(seq 1 300); do
|
||||
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready" && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::ComfyUI server did not start within 600s" && exit 1
|
||||
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -13,6 +13,8 @@ runs:
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -22,6 +22,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -20,6 +20,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -74,6 +76,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
22
.github/workflows/ci-tests-e2e.yaml
vendored
22
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -33,27 +33,13 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-dist-cloud
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Sharded chromium tests
|
||||
playwright-tests-chromium-sharded:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -101,7 +87,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -111,14 +97,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Download built frontend
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
|
||||
- name: Start ComfyUI server
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -30,6 +30,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
993
.github/workflows/pr-qa.yaml
vendored
Normal file
993
.github/workflows/pr-qa.yaml
vendored
Normal file
@@ -0,0 +1,993 @@
|
||||
# Automated QA of ComfyUI frontend using Playwright video recordings + Gemini review.
|
||||
# Architecture:
|
||||
# resolve-matrix → qa-before (main) ─┐
|
||||
# → qa-after (PR) ─┴→ report
|
||||
#
|
||||
# Before/after run in PARALLEL on separate runners for clean isolation.
|
||||
# Two modes:
|
||||
# Focused (qa-changes label): Linux-only, before/after comparison
|
||||
# Full (qa-full label): 3-OS matrix, after-only
|
||||
name: 'PR: QA'
|
||||
|
||||
on:
|
||||
# TODO: remove push trigger before merge
|
||||
push:
|
||||
branches: [sno-skills, sno-qa-*]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'QA mode'
|
||||
type: choice
|
||||
options: [focused, full]
|
||||
default: focused
|
||||
|
||||
# TODO: restore concurrency group before merge (disabled for parallel sno-qa-* testing)
|
||||
# concurrency:
|
||||
# group: ${{ github.workflow }}-${{ github.ref }}
|
||||
# cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
resolve-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
os: ${{ steps.set.outputs.os }}
|
||||
mode: ${{ steps.set.outputs.mode }}
|
||||
skip: ${{ steps.set.outputs.skip }}
|
||||
number: ${{ steps.resolve-number.outputs.number }}
|
||||
target_type: ${{ steps.resolve-number.outputs.target_type }}
|
||||
before_sha: ${{ steps.resolve-refs.outputs.before_sha }}
|
||||
after_sha: ${{ steps.resolve-refs.outputs.after_sha }}
|
||||
steps:
|
||||
- name: Determine QA mode
|
||||
id: set
|
||||
env:
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_MODE: ${{ inputs.mode }}
|
||||
run: |
|
||||
FULL=false
|
||||
|
||||
# Only run on label events if it's one of our labels
|
||||
if [ "$EVENT_ACTION" = "labeled" ] && \
|
||||
[ "$LABEL" != "qa-changes" ] && [ "$LABEL" != "qa-full" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Full QA triggers
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && \
|
||||
[ "$INPUT_MODE" = "full" ]; then
|
||||
FULL=true
|
||||
fi
|
||||
if [ "$LABEL" = "qa-full" ]; then
|
||||
FULL=true
|
||||
fi
|
||||
|
||||
if [ "$FULL" = "true" ]; then
|
||||
echo 'os=["ubuntu-latest","macos-latest","windows-latest"]' >> "$GITHUB_OUTPUT"
|
||||
echo "mode=full" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
||||
echo "mode=focused" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Mode: $([ "$FULL" = "true" ] && echo full || echo focused)"
|
||||
|
||||
- name: Resolve target number and type
|
||||
id: resolve-number
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
NUM="$PR_NUM"
|
||||
else
|
||||
NUM=$(gh pr list --repo "$REPO" \
|
||||
--head "$BRANCH" --state open \
|
||||
--json number --jq '.[0].number // empty')
|
||||
if [ -z "$NUM" ]; then
|
||||
NUM=$(echo "$BRANCH" | sed -n 's/^sno-qa-\([0-9]\+\)$/\1/p')
|
||||
fi
|
||||
fi
|
||||
echo "number=${NUM}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -n "$NUM" ]; then
|
||||
# Use the API to check if it's a PR (gh pr view can't distinguish)
|
||||
if gh api "repos/${REPO}/pulls/${NUM}" --jq '.number' >/dev/null 2>&1; then
|
||||
echo "target_type=pr" >> "$GITHUB_OUTPUT"
|
||||
echo "Target: PR #$NUM"
|
||||
else
|
||||
echo "target_type=issue" >> "$GITHUB_OUTPUT"
|
||||
echo "Target: Issue #$NUM"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Resolve commit SHAs for immutable references
|
||||
id: resolve-refs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NUM: ${{ steps.resolve-number.outputs.number }}
|
||||
TARGET_TYPE: ${{ steps.resolve-number.outputs.target_type }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
MAIN_SHA=$(gh api "repos/${REPO}/git/ref/heads/main" --jq '.object.sha')
|
||||
echo "before_sha=${MAIN_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "Main: ${MAIN_SHA:0:7}"
|
||||
|
||||
if [ "$TARGET_TYPE" = "pr" ] && [ -n "$NUM" ]; then
|
||||
PR_SHA=$(gh pr view "$NUM" --repo "$REPO" --json headRefOid --jq '.headRefOid')
|
||||
echo "after_sha=${PR_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "PR #${NUM}: ${PR_SHA:0:7}"
|
||||
fi
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Analyze PR — deep analysis via Gemini Pro to generate QA guides
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
analyze-pr:
|
||||
needs: [resolve-matrix]
|
||||
if: needs.resolve-matrix.outputs.skip != 'true' && needs.resolve-matrix.outputs.mode == 'focused'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
|
||||
- name: Setup frontend (scripts only)
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: false
|
||||
|
||||
- name: Run analysis
|
||||
if: needs.resolve-matrix.outputs.number
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p qa-guides
|
||||
pnpm exec tsx scripts/qa-analyze-pr.ts \
|
||||
--pr-number "${{ needs.resolve-matrix.outputs.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--output-dir qa-guides \
|
||||
--type "${{ needs.resolve-matrix.outputs.target_type }}"
|
||||
|
||||
- name: Upload QA guides
|
||||
if: always() && needs.resolve-matrix.outputs.number
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
|
||||
with:
|
||||
name: qa-guides-${{ github.run_id }}
|
||||
path: qa-guides/qa-guide-*.json
|
||||
retention-days: 14
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# BEFORE recording — main branch frontend on its own runner
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
qa-before:
|
||||
needs: [resolve-matrix, analyze-pr]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.resolve-matrix.outputs.skip != 'true' &&
|
||||
needs.resolve-matrix.outputs.mode == 'focused' &&
|
||||
needs.resolve-matrix.result == 'success'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJson(needs.resolve-matrix.outputs.os) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Set QA artifacts path
|
||||
shell: bash
|
||||
run: echo "QA_ARTIFACTS=$RUNNER_TEMP/qa-artifacts" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Install pnpm/node for PR checkout (needed for tsx scripts later)
|
||||
- name: Setup frontend (scripts only)
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: false
|
||||
|
||||
- name: Build main branch frontend via worktree
|
||||
shell: bash
|
||||
run: |
|
||||
git worktree add ../main-build origin/main
|
||||
cd ../main-build
|
||||
pnpm install --frozen-lockfile || pnpm install
|
||||
pnpm exec vite build
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
mv ../main-build/dist dist
|
||||
git worktree remove ../main-build --force
|
||||
echo "Built main branch frontend"
|
||||
ls -la dist/index.html
|
||||
|
||||
- name: Setup ComfyUI server (no launch)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: 'false'
|
||||
|
||||
- name: Install Playwright browser
|
||||
shell: bash
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
mkdir -p "$QA_ARTIFACTS"
|
||||
|
||||
- name: Get PR diff
|
||||
if: needs.resolve-matrix.outputs.target_type == 'pr'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr diff ${{ github.event.pull_request.number || '' }} \
|
||||
--repo ${{ github.repository }} > "${{ runner.temp }}/pr-diff.txt" 2>/dev/null || \
|
||||
git diff origin/main...HEAD > "${{ runner.temp }}/pr-diff.txt"
|
||||
|
||||
echo "Changed files:"
|
||||
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
|
||||
sed 's|diff --git a/||;s| b/.*||' | sort -u
|
||||
|
||||
- name: Get issue body (for reproduce mode)
|
||||
if: needs.resolve-matrix.outputs.target_type == 'issue' && needs.resolve-matrix.outputs.number
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh issue view ${{ needs.resolve-matrix.outputs.number }} \
|
||||
--repo ${{ github.repository }} \
|
||||
--json title,body --jq '.title + "\n\n" + .body' \
|
||||
> "${{ runner.temp }}/issue-body.txt"
|
||||
echo "Issue body saved ($(wc -c < "${{ runner.temp }}/issue-body.txt") bytes)"
|
||||
|
||||
- name: Download QA guide
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: qa-guides-${{ github.run_id }}
|
||||
path: qa-guides
|
||||
|
||||
- name: Start server with main branch frontend
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist &
|
||||
echo $! > /tmp/comfyui-server.pid
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready (main)" && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::Server timeout (main)"; exit 1
|
||||
|
||||
- name: Pre-seed settings
|
||||
shell: bash
|
||||
run: |
|
||||
curl -sf -X POST http://127.0.0.1:8188/api/users \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"qa-ci"}' || echo "User creation failed (may already exist)"
|
||||
curl -sf -X POST http://127.0.0.1:8188/api/devtools/set_settings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"Comfy.TutorialCompleted":true}' || \
|
||||
curl -sf -X POST http://127.0.0.1:8188/api/settings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"Comfy.TutorialCompleted":true}' || echo "Settings pre-seed skipped"
|
||||
|
||||
- name: Run QA recording
|
||||
shell: bash
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
TARGET_TYPE: ${{ needs.resolve-matrix.outputs.target_type }}
|
||||
run: |
|
||||
MODE="before"
|
||||
if [ "$TARGET_TYPE" = "issue" ]; then
|
||||
MODE="reproduce"
|
||||
fi
|
||||
|
||||
QA_GUIDE_FLAG=""
|
||||
if [ -f qa-guides/qa-guide-before.json ]; then
|
||||
echo "Using QA guide for $MODE recording"
|
||||
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-before.json"
|
||||
fi
|
||||
|
||||
DIFF_FLAG=""
|
||||
if [ -f "${{ runner.temp }}/pr-diff.txt" ]; then
|
||||
DIFF_FLAG="--diff ${{ runner.temp }}/pr-diff.txt"
|
||||
elif [ -f "${{ runner.temp }}/issue-body.txt" ]; then
|
||||
DIFF_FLAG="--diff ${{ runner.temp }}/issue-body.txt"
|
||||
fi
|
||||
|
||||
pnpm exec tsx scripts/qa-record.ts \
|
||||
--mode "$MODE" \
|
||||
$DIFF_FLAG \
|
||||
--output-dir "$QA_ARTIFACTS" \
|
||||
--url http://127.0.0.1:8188 \
|
||||
--test-plan .claude/skills/comfy-qa/SKILL.md \
|
||||
$QA_GUIDE_FLAG
|
||||
|
||||
- name: Collect artifacts
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
kill "$(cat /tmp/comfyui-server.pid)" 2>/dev/null || true
|
||||
echo "=== QA BEFORE artifacts ==="
|
||||
ls -la "$QA_ARTIFACTS/" 2>/dev/null | head -30
|
||||
|
||||
- name: Upload QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
|
||||
with:
|
||||
name: qa-before-${{ runner.os }}-${{ github.run_id }}
|
||||
path: ${{ env.QA_ARTIFACTS }}/
|
||||
retention-days: 14
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# AFTER recording — PR branch frontend on its own runner
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
qa-after:
|
||||
needs: [resolve-matrix, analyze-pr]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.resolve-matrix.outputs.skip != 'true' &&
|
||||
needs.resolve-matrix.result == 'success' &&
|
||||
needs.resolve-matrix.outputs.target_type == 'pr'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJson(needs.resolve-matrix.outputs.os) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Set QA artifacts path
|
||||
shell: bash
|
||||
run: echo "QA_ARTIFACTS=$RUNNER_TEMP/qa-artifacts" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# When triggered via sno-qa-* push, the checkout above gets sno-skills
|
||||
# (the scripts branch), not the actual PR. Fetch the PR ref and check it out.
|
||||
- name: Checkout PR head for sno-qa-* triggers
|
||||
if: >-
|
||||
!github.head_ref &&
|
||||
needs.resolve-matrix.outputs.target_type == 'pr' &&
|
||||
needs.resolve-matrix.outputs.number
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUM: ${{ needs.resolve-matrix.outputs.number }}
|
||||
run: |
|
||||
git fetch origin "refs/pull/${PR_NUM}/head"
|
||||
git checkout FETCH_HEAD
|
||||
echo "Checked out PR #${PR_NUM} at $(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Setup frontend (PR branch)
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
|
||||
- name: Setup ComfyUI server (no launch)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: 'false'
|
||||
|
||||
- name: Install Playwright browser
|
||||
shell: bash
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
mkdir -p "$QA_ARTIFACTS"
|
||||
|
||||
- name: Get PR diff
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr diff ${{ github.event.pull_request.number || '' }} \
|
||||
--repo ${{ github.repository }} > "${{ runner.temp }}/pr-diff.txt" 2>/dev/null || \
|
||||
git diff origin/main...HEAD > "${{ runner.temp }}/pr-diff.txt"
|
||||
|
||||
echo "Changed files:"
|
||||
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
|
||||
sed 's|diff --git a/||;s| b/.*||' | sort -u
|
||||
|
||||
- name: Download QA guide
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: qa-guides-${{ github.run_id }}
|
||||
path: qa-guides
|
||||
|
||||
- name: Start server with PR branch frontend
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist &
|
||||
echo $! > /tmp/comfyui-server.pid
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready (PR)" && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::Server timeout (PR)"; exit 1
|
||||
|
||||
- name: Pre-seed settings
|
||||
shell: bash
|
||||
run: |
|
||||
curl -sf -X POST http://127.0.0.1:8188/api/users \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"qa-ci"}' || echo "User creation failed (may already exist)"
|
||||
curl -sf -X POST http://127.0.0.1:8188/api/devtools/set_settings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"Comfy.TutorialCompleted":true}' || \
|
||||
curl -sf -X POST http://127.0.0.1:8188/api/settings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"Comfy.TutorialCompleted":true}' || echo "Settings pre-seed skipped"
|
||||
|
||||
- name: Run AFTER QA recording
|
||||
shell: bash
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
run: |
|
||||
QA_GUIDE_FLAG=""
|
||||
if [ -f qa-guides/qa-guide-after.json ]; then
|
||||
echo "Using QA guide for after recording"
|
||||
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-after.json"
|
||||
fi
|
||||
pnpm exec tsx scripts/qa-record.ts \
|
||||
--mode after \
|
||||
--diff "${{ runner.temp }}/pr-diff.txt" \
|
||||
--output-dir "$QA_ARTIFACTS" \
|
||||
--url http://127.0.0.1:8188 \
|
||||
--test-plan .claude/skills/comfy-qa/SKILL.md \
|
||||
$QA_GUIDE_FLAG
|
||||
|
||||
- name: Collect artifacts
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
kill "$(cat /tmp/comfyui-server.pid)" 2>/dev/null || true
|
||||
echo "=== QA AFTER artifacts ==="
|
||||
ls -la "$QA_ARTIFACTS/" 2>/dev/null | head -30
|
||||
|
||||
- name: Upload QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
|
||||
with:
|
||||
name: qa-after-${{ runner.os }}-${{ github.run_id }}
|
||||
path: ${{ env.QA_ARTIFACTS }}/
|
||||
retention-days: 14
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Report — merges artifacts, runs Gemini video review, deploys
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
report:
|
||||
needs: [resolve-matrix, analyze-pr, qa-before, qa-after]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.qa-after.result == 'success' || needs.qa-before.result == 'success') &&
|
||||
(github.event.pull_request.number || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Setup badge deploy function
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
RAW_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
npm install -g wrangler@4.74.0 >/dev/null 2>&1
|
||||
BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-28)
|
||||
echo "QA_BRANCH=$BRANCH" >> "$GITHUB_ENV"
|
||||
|
||||
# Create badge generator script
|
||||
cat > /tmp/gen-badge.sh <<'BADGESCRIPT'
|
||||
#!/bin/bash
|
||||
# Usage: gen-badge.sh <status> <color> <output-path> [label]
|
||||
STATUS="$1" COLOR="$2" OUT="$3"
|
||||
LABEL="${4:-QA Bot}"
|
||||
LABEL_W=$(( ${#LABEL} * 7 + 12 ))
|
||||
STATUS_W=$(( ${#STATUS} * 7 + 12 ))
|
||||
TOTAL_W=$(( LABEL_W + STATUS_W ))
|
||||
cat > "$OUT" <<SVGEOF
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${TOTAL_W}" height="20" role="img" aria-label="${LABEL}: ${STATUS}">
|
||||
<title>${LABEL}: ${STATUS}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
||||
<clipPath id="r"><rect width="${TOTAL_W}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="${LABEL_W}" height="20" fill="#555"/>
|
||||
<rect x="${LABEL_W}" width="${STATUS_W}" height="20" fill="${COLOR}"/>
|
||||
<rect width="${TOTAL_W}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="$(( LABEL_W / 2 ))" y="15" fill="#010101" fill-opacity=".3">${LABEL}</text>
|
||||
<text x="$(( LABEL_W / 2 ))" y="14">${LABEL}</text>
|
||||
<text aria-hidden="true" x="$(( LABEL_W + STATUS_W / 2 ))" y="15" fill="#010101" fill-opacity=".3">${STATUS}</text>
|
||||
<text x="$(( LABEL_W + STATUS_W / 2 ))" y="14">${STATUS}</text>
|
||||
</g>
|
||||
</svg>
|
||||
SVGEOF
|
||||
BADGESCRIPT
|
||||
chmod +x /tmp/gen-badge.sh
|
||||
|
||||
# Create badge deploy script
|
||||
cat > /tmp/deploy-badge.sh <<DEPLOYSCRIPT
|
||||
#!/bin/bash
|
||||
# Usage: deploy-badge.sh <status> [color]
|
||||
STATUS="\$1"
|
||||
COLOR="\${2:-#555}"
|
||||
DIR=\$(mktemp -d)
|
||||
/tmp/gen-badge.sh "\$STATUS" "\$COLOR" "\$DIR/badge.svg"
|
||||
# Also create a minimal redirect page
|
||||
echo '<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=badge.svg"></head></html>' > "\$DIR/index.html"
|
||||
wrangler pages deploy "\$DIR" \
|
||||
--project-name="comfy-qa" \
|
||||
--branch="${BRANCH}" 2>&1 | tail -3
|
||||
rm -rf "\$DIR"
|
||||
echo "Badge deployed: ${STATUS}"
|
||||
DEPLOYSCRIPT
|
||||
chmod +x /tmp/deploy-badge.sh
|
||||
|
||||
- name: Setup dual badge generator
|
||||
run: |
|
||||
cat > /tmp/gen-badge-dual.sh <<'DUALBADGE'
|
||||
#!/bin/bash
|
||||
# Usage: gen-badge-dual.sh <repro> <repro_color> <fix> <fix_color> <output-path> [label]
|
||||
BUG="$1" BUG_C="$2" FIX="Fix: $3" FIX_C="$4" OUT="$5"
|
||||
LABEL="${6:-QA Bot}"
|
||||
LW=$(( ${#LABEL} * 7 + 12 ))
|
||||
BW=$(( ${#BUG} * 7 + 12 ))
|
||||
FW=$(( ${#FIX} * 7 + 12 ))
|
||||
TW=$(( LW + BW + FW ))
|
||||
cat > "$OUT" <<SVGEOF
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${TW}" height="20" role="img" aria-label="${LABEL}: ${BUG} | ${FIX}">
|
||||
<title>${LABEL}: ${BUG} | ${FIX}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
||||
<clipPath id="r"><rect width="${TW}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="${LW}" height="20" fill="#555"/>
|
||||
<rect x="${LW}" width="${BW}" height="20" fill="${BUG_C}"/>
|
||||
<rect x="$(( LW + BW ))" width="${FW}" height="20" fill="${FIX_C}"/>
|
||||
<rect width="${TW}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="$(( LW / 2 ))" y="15" fill="#010101" fill-opacity=".3">${LABEL}</text>
|
||||
<text x="$(( LW / 2 ))" y="14">${LABEL}</text>
|
||||
<text aria-hidden="true" x="$(( LW + BW / 2 ))" y="15" fill="#010101" fill-opacity=".3">${BUG}</text>
|
||||
<text x="$(( LW + BW / 2 ))" y="14">${BUG}</text>
|
||||
<text aria-hidden="true" x="$(( LW + BW + FW / 2 ))" y="15" fill="#010101" fill-opacity=".3">${FIX}</text>
|
||||
<text x="$(( LW + BW + FW / 2 ))" y="14">${FIX}</text>
|
||||
</g>
|
||||
</svg>
|
||||
SVGEOF
|
||||
DUALBADGE
|
||||
chmod +x /tmp/gen-badge-dual.sh
|
||||
|
||||
- name: Deploy badge — PREPARING
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: /tmp/deploy-badge.sh "PREPARING" "#2196f3"
|
||||
|
||||
- name: Resolve target number and type
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
NUM="$PR_NUM"
|
||||
else
|
||||
NUM=$(gh pr list --repo "$REPO" \
|
||||
--head "$BRANCH" --state open \
|
||||
--json number --jq '.[0].number // empty')
|
||||
if [ -z "$NUM" ]; then
|
||||
NUM=$(echo "$BRANCH" | sed -n 's/^sno-qa-\([0-9]\+\)$/\1/p')
|
||||
fi
|
||||
fi
|
||||
echo "number=${NUM}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -n "$NUM" ]; then
|
||||
if gh api "repos/${REPO}/pulls/${NUM}" --jq '.number' >/dev/null 2>&1; then
|
||||
echo "target_type=pr" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "target_type=issue" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download BEFORE artifacts
|
||||
if: needs.qa-before.result == 'success'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
path: qa-artifacts/before
|
||||
pattern: qa-before-*
|
||||
|
||||
- name: Download AFTER artifacts
|
||||
if: needs.qa-after.result == 'success'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
path: qa-artifacts/after
|
||||
pattern: qa-after-*
|
||||
|
||||
- name: Merge artifacts into per-OS report directories
|
||||
run: |
|
||||
echo "=== Downloaded BEFORE artifacts ==="
|
||||
find qa-artifacts/before -type f 2>/dev/null | head -20
|
||||
echo "=== Downloaded AFTER artifacts ==="
|
||||
find qa-artifacts/after -type f 2>/dev/null | head -20
|
||||
|
||||
for os in Linux macOS Windows; do
|
||||
REPORT_DIR="qa-artifacts/qa-report-${os}-${{ github.run_id }}"
|
||||
HAS_FILES=false
|
||||
|
||||
# Check for before files (flat or in subdirectory)
|
||||
if [ -d "qa-artifacts/before" ] && find qa-artifacts/before -name '*.webm' -o -name '*.png' 2>/dev/null | grep -q .; then
|
||||
HAS_FILES=true
|
||||
fi
|
||||
# Check for after files
|
||||
if [ -d "qa-artifacts/after" ] && find qa-artifacts/after -name '*.webm' -o -name '*.png' 2>/dev/null | grep -q .; then
|
||||
HAS_FILES=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_FILES" = true ]; then
|
||||
mkdir -p "$REPORT_DIR"
|
||||
# Copy all before files (handles both flat and nested layouts)
|
||||
find qa-artifacts/before -type f 2>/dev/null | while read f; do
|
||||
cp "$f" "$REPORT_DIR/" 2>/dev/null || true
|
||||
done
|
||||
# Copy all after files (overwrites duplicates with after versions)
|
||||
find qa-artifacts/after -type f 2>/dev/null | while read f; do
|
||||
cp "$f" "$REPORT_DIR/" 2>/dev/null || true
|
||||
done
|
||||
echo "Merged $os artifacts into $REPORT_DIR"
|
||||
ls -la "$REPORT_DIR/" | head -20
|
||||
break # Only create one report dir (multi-OS not yet supported in parallel mode)
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
if command -v ffmpeg &>/dev/null; then
|
||||
echo "ffmpeg already installed"
|
||||
else
|
||||
echo "Downloading static ffmpeg..."
|
||||
TMP=$(mktemp -d)
|
||||
curl -sL "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" | tar xJ -C "$TMP"
|
||||
sudo cp "$TMP"/ffmpeg-*/ffmpeg "$TMP"/ffmpeg-*/ffprobe /usr/local/bin/
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
ffmpeg -version | head -1
|
||||
ffprobe -version | head -1
|
||||
|
||||
- name: Convert videos to mp4
|
||||
run: |
|
||||
convert_video() {
|
||||
local WEBM="$1" MP4="$2"
|
||||
echo "Converting $WEBM ($(du -h "$WEBM" | cut -f1)) to $MP4"
|
||||
ffmpeg -y -i "$WEBM" \
|
||||
-c:v libx264 -preset ultrafast -crf 23 -pix_fmt yuv420p \
|
||||
-movflags +faststart -g 60 \
|
||||
"$MP4" 2>&1 | tail -5 \
|
||||
|| echo "ffmpeg conversion failed for $WEBM (non-fatal)"
|
||||
[ -f "$MP4" ] && echo "Created: $MP4 ($(du -h "$MP4" | cut -f1))"
|
||||
}
|
||||
|
||||
for dir in qa-artifacts/qa-report-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
# Convert known video names (single + multi-pass + before)
|
||||
for name in qa-session qa-session-1 qa-session-2 qa-session-3 qa-before-session; do
|
||||
if [ -f "$dir/${name}.webm" ] && [ -s "$dir/${name}.webm" ]; then
|
||||
convert_video "$dir/${name}.webm" "$dir/${name}.mp4"
|
||||
fi
|
||||
done
|
||||
# Fallback: find any non-empty webm not yet converted
|
||||
if [ ! -f "$dir/qa-session.mp4" ] && [ ! -f "$dir/qa-session-1.mp4" ]; then
|
||||
WEBM=$(find "$dir" -name '*.webm' -type f -size +0c | head -1)
|
||||
[ -n "$WEBM" ] && convert_video "$WEBM" "$dir/qa-session.mp4"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Deploy badge — ANALYZING
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: /tmp/deploy-badge.sh "ANALYZING" "#ff9800"
|
||||
|
||||
- name: Build context for video review
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TARGET_NUM: ${{ steps.pr.outputs.number }}
|
||||
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$TARGET_NUM" ]; then
|
||||
echo "No target number available, skipping context"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$TARGET_TYPE" = "issue" ]; then
|
||||
{
|
||||
echo "### Issue #${TARGET_NUM}"
|
||||
gh issue view "$TARGET_NUM" --repo "$REPO" \
|
||||
--json title,body --jq '"Title: \(.title)\n\nDescription:\n\(.body)"' 2>/dev/null || true
|
||||
echo ""
|
||||
echo "### Comments"
|
||||
gh api "repos/${REPO}/issues/${TARGET_NUM}/comments" \
|
||||
--jq '.[].body' 2>/dev/null | head -200 || true
|
||||
echo ""
|
||||
echo "This video attempts to reproduce a reported bug on the main branch."
|
||||
} > pr-context.txt
|
||||
echo "Issue context saved ($(wc -l < pr-context.txt) lines)"
|
||||
else
|
||||
{
|
||||
echo "### PR #${TARGET_NUM}"
|
||||
gh pr view "$TARGET_NUM" --repo "$REPO" \
|
||||
--json title,body --jq '"Title: \(.title)\n\nDescription:\n\(.body)"' 2>/dev/null || true
|
||||
echo ""
|
||||
echo "### Changed files"
|
||||
gh pr diff "$TARGET_NUM" --repo "$REPO" 2>/dev/null \
|
||||
| grep '^diff --git' | sed 's|diff --git a/||;s| b/.*||' | sort -u || true
|
||||
echo ""
|
||||
echo "### Diff (truncated to 300 lines)"
|
||||
gh pr diff "$TARGET_NUM" --repo "$REPO" 2>/dev/null \
|
||||
| head -300 || true
|
||||
} > pr-context.txt
|
||||
echo "PR context saved ($(wc -l < pr-context.txt) lines)"
|
||||
fi
|
||||
|
||||
- name: Run video review
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
TARGET_NUM: ${{ steps.pr.outputs.number }}
|
||||
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
mkdir -p video-reviews
|
||||
PR_CTX_FLAG=""
|
||||
if [ -f pr-context.txt ]; then
|
||||
PR_CTX_FLAG="--pr-context pr-context.txt"
|
||||
fi
|
||||
TARGET_URL_FLAG=""
|
||||
if [ -n "$TARGET_NUM" ]; then
|
||||
if [ "$TARGET_TYPE" = "issue" ]; then
|
||||
TARGET_URL_FLAG="--target-url https://github.com/${REPO}/issues/${TARGET_NUM}"
|
||||
else
|
||||
TARGET_URL_FLAG="--target-url https://github.com/${REPO}/pull/${TARGET_NUM}"
|
||||
fi
|
||||
fi
|
||||
for vid in qa-artifacts/qa-report-*/qa-session*.mp4; do
|
||||
[ -f "$vid" ] || continue
|
||||
DIR=$(dirname "$vid")
|
||||
BEFORE_FLAG=""
|
||||
if [ -f "$DIR/qa-before-session.mp4" ]; then
|
||||
BEFORE_FLAG="--before-video $DIR/qa-before-session.mp4"
|
||||
fi
|
||||
# Extract pass label from multi-pass filenames (qa-session-1.mp4 → pass1)
|
||||
PASS_LABEL_FLAG=""
|
||||
case "$(basename "$vid")" in
|
||||
qa-session-[0-9].mp4)
|
||||
PASS_NUM=$(basename "$vid" | sed 's/qa-session-\([0-9]\).mp4/\1/')
|
||||
PASS_LABEL_FLAG="--pass-label pass${PASS_NUM}"
|
||||
;;
|
||||
esac
|
||||
echo "::group::Reviewing $vid"
|
||||
pnpm exec tsx scripts/qa-video-review.ts \
|
||||
--artifacts-dir qa-artifacts \
|
||||
--output-dir video-reviews \
|
||||
--video-file "$vid" \
|
||||
--model gemini-3-flash-preview $PR_CTX_FLAG $BEFORE_FLAG $TARGET_URL_FLAG $PASS_LABEL_FLAG || true
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
- name: Generate regression test from QA report
|
||||
if: needs.resolve-matrix.outputs.mode == 'focused' && steps.pr.outputs.target_type == 'pr'
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUM="${{ steps.pr.outputs.number }}"
|
||||
PR_BRANCH="${{ github.head_ref || github.ref_name }}"
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
echo "No PR number, skipping test generation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the first QA report
|
||||
REPORT=$(find video-reviews -name '*-qa-video-report.md' -type f | head -1)
|
||||
if [ ! -f "$REPORT" ]; then
|
||||
echo "No QA report found, skipping test generation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure we have the PR diff
|
||||
DIFF_FILE="${{ runner.temp }}/pr-diff.txt"
|
||||
if [ ! -f "$DIFF_FILE" ]; then
|
||||
gh pr diff "$PR_NUM" --repo "${{ github.repository }}" > "$DIFF_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Generate the test
|
||||
TEST_NAME="qa-pr${PR_NUM}"
|
||||
TEST_PATH="browser_tests/tests/${TEST_NAME}.spec.ts"
|
||||
echo "::group::Generating regression test from QA report"
|
||||
pnpm exec tsx scripts/qa-generate-test.ts \
|
||||
--qa-report "$REPORT" \
|
||||
--pr-diff "$DIFF_FILE" \
|
||||
--output "$TEST_PATH" || {
|
||||
echo "Test generation failed (non-fatal)"
|
||||
exit 0
|
||||
}
|
||||
echo "::endgroup::"
|
||||
|
||||
# Push to {branch}-add-qa-test
|
||||
TEST_BRANCH="${PR_BRANCH}-add-qa-test"
|
||||
git checkout -b "$TEST_BRANCH" HEAD 2>/dev/null || git checkout "$TEST_BRANCH" 2>/dev/null || true
|
||||
git add "$TEST_PATH"
|
||||
git commit -m "test: add QA regression test for PR #${PR_NUM}" || {
|
||||
echo "Nothing to commit"
|
||||
exit 0
|
||||
}
|
||||
git push origin "$TEST_BRANCH" --force-with-lease || echo "Push failed (non-fatal)"
|
||||
echo "Pushed regression test to branch: $TEST_BRANCH"
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
id: deploy-videos
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
RAW_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
BEFORE_SHA: ${{ needs.resolve-matrix.outputs.before_sha }}
|
||||
AFTER_SHA: ${{ needs.resolve-matrix.outputs.after_sha }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
TARGET_NUM: ${{ steps.pr.outputs.number }}
|
||||
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/qa-deploy-pages.sh
|
||||
|
||||
- name: Post unified QA comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VIDEO_BASE: ${{ steps.deploy-videos.outputs.url }}
|
||||
QA_MODE: ${{ needs.resolve-matrix.outputs.mode }}
|
||||
TARGET_TYPE: ${{ steps.pr.outputs.target_type }}
|
||||
BEFORE_SHA: ${{ needs.resolve-matrix.outputs.before_sha }}
|
||||
AFTER_SHA: ${{ needs.resolve-matrix.outputs.after_sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
RUN="https://github.com/${REPO}/actions/runs/${{ github.run_id }}"
|
||||
COMMENT_MARKER="<!-- QA_REPORT_COMMENT -->"
|
||||
|
||||
MODE_BADGE="🔍 Focused"
|
||||
if [ "$QA_MODE" = "full" ]; then MODE_BADGE="🔬 Full (3-OS)"; fi
|
||||
if [ "$TARGET_TYPE" = "issue" ]; then MODE_BADGE="🐛 Issue Reproduce"; fi
|
||||
|
||||
# Build commit links
|
||||
COMMIT_LINE=""
|
||||
REPO_URL="https://github.com/${REPO}"
|
||||
if [ -n "$BEFORE_SHA" ] || [ -n "$AFTER_SHA" ]; then
|
||||
PARTS=""
|
||||
[ -n "$BEFORE_SHA" ] && PARTS="main [\`${BEFORE_SHA:0:7}\`](${REPO_URL}/commit/${BEFORE_SHA})"
|
||||
[ -n "$AFTER_SHA" ] && PARTS="${PARTS:+${PARTS} · }PR [\`${AFTER_SHA:0:7}\`](${REPO_URL}/commit/${AFTER_SHA})"
|
||||
COMMIT_LINE="**Commits**: ${PARTS}"
|
||||
fi
|
||||
|
||||
# Build video section with GIF thumbnails linking to full videos
|
||||
VIDEO_SECTION=""
|
||||
for os in Linux macOS Windows; do
|
||||
GIF_URL="${VIDEO_BASE}/qa-${os}-thumb.gif"
|
||||
VID_URL="${VIDEO_BASE}/qa-${os}.mp4"
|
||||
if curl -sf --head "$VID_URL" >/dev/null 2>&1; then
|
||||
if curl -sf --head "$GIF_URL" >/dev/null 2>&1; then
|
||||
VIDEO_SECTION="${VIDEO_SECTION}[](${VID_URL})"$'\n'
|
||||
else
|
||||
VIDEO_SECTION="${VIDEO_SECTION}[${os} video](${VID_URL})"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Build video review section from per-platform reports
|
||||
VIDEO_REVIEW=""
|
||||
for f in video-reviews/*-qa-video-report.md; do
|
||||
[ -f "$f" ] || continue
|
||||
[ -n "$VIDEO_REVIEW" ] && VIDEO_REVIEW="${VIDEO_REVIEW}
|
||||
---
|
||||
"
|
||||
VIDEO_REVIEW="${VIDEO_REVIEW}$(cat "$f")"
|
||||
done
|
||||
|
||||
VIDEO_REVIEW_SECTION=""
|
||||
if [ -n "$VIDEO_REVIEW" ]; then
|
||||
VIDEO_REVIEW_SECTION=$(cat <<REVIEWEOF
|
||||
|
||||
<details>
|
||||
<summary>Video Review</summary>
|
||||
|
||||
${VIDEO_REVIEW}
|
||||
|
||||
</details>
|
||||
REVIEWEOF
|
||||
)
|
||||
fi
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
${COMMENT_MARKER}
|
||||
## QA ${MODE_BADGE}
|
||||
|
||||
[](${VIDEO_BASE}/)
|
||||
|
||||
${VIDEO_SECTION}
|
||||
**Run**: [${RUN}](${RUN}) · [Download artifacts](${RUN}#artifacts) · [All videos](${VIDEO_BASE})
|
||||
${COMMIT_LINE:+${COMMIT_LINE}
|
||||
}${VIDEO_REVIEW_SECTION}
|
||||
EOF
|
||||
)
|
||||
|
||||
PR_NUM="${{ steps.pr.outputs.number }}"
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
echo "No PR found, skipping comment"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXISTING=$(gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \
|
||||
--jq ".[] | select(.body | contains(\"${COMMENT_MARKER}\")) | .id" | head -1)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh api --method PATCH "repos/${{ github.repository }}/issues/comments/${EXISTING}" \
|
||||
--field body="$BODY"
|
||||
elif [ "$TARGET_TYPE" = "issue" ]; then
|
||||
gh issue comment "$PR_NUM" \
|
||||
--repo ${{ github.repository }} --body "$BODY"
|
||||
else
|
||||
gh pr comment "$PR_NUM" \
|
||||
--repo ${{ github.repository }} --body "$BODY"
|
||||
fi
|
||||
|
||||
- name: Cleanup old video review comments
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUM="${{ steps.pr.outputs.number }}"
|
||||
if [ -z "$PR_NUM" ]; then exit 0; fi
|
||||
OLD_MARKER="<!-- QA_VIDEO_REVIEW_COMMENT -->"
|
||||
gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \
|
||||
--jq ".[] | select(.body | contains(\"${OLD_MARKER}\")) | .id" | \
|
||||
while read -r comment_id; do
|
||||
echo "Deleting old video review comment: $comment_id"
|
||||
gh api --method DELETE "repos/${{ github.repository }}/issues/comments/${comment_id}" || true
|
||||
done
|
||||
|
||||
- name: Remove QA label
|
||||
if: >-
|
||||
github.event.label.name == 'qa-changes' ||
|
||||
github.event.label.name == 'qa-full'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
[ -n "$PR_NUMBER" ] && gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL_NAME"
|
||||
2
.github/workflows/pr-report.yaml
vendored
2
.github/workflows/pr-report.yaml
vendored
@@ -180,7 +180,7 @@ jobs:
|
||||
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
|
||||
git fetch origin perf-data --depth=1
|
||||
mkdir -p temp/perf-history
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
|
||||
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
|
||||
done
|
||||
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -85,6 +85,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -76,6 +76,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -201,6 +203,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
4
.github/workflows/release-draft-create.yaml
vendored
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
2
.github/workflows/release-npm-types.yaml
vendored
@@ -76,6 +76,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
4
.github/workflows/release-pypi-dev.yaml
vendored
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
2
.github/workflows/release-version-bump.yaml
vendored
@@ -144,6 +144,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -52,6 +52,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -30,6 +30,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -66,7 +66,6 @@ dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
.superpowers/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
@@ -100,4 +99,6 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
.playwright-cli/
|
||||
.playwright/
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/__fixtures__/**/*.json"
|
||||
"**/__fixtures__/**/*.json",
|
||||
"scripts/qa-report-template.html"
|
||||
]
|
||||
}
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -208,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
3. Keep your module mocks contained
|
||||
Do not use global mutable state within the test file
|
||||
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
|
||||
4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
@@ -231,18 +231,6 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
All architectural decisions are documented in `docs/adr/`. Code changes must be consistent with accepted ADRs. Proposed ADRs indicate design direction and should be treated as guidance. See `.agents/checks/adr-compliance.md` for automated validation rules.
|
||||
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
|
||||
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
- Follow good software engineering principles
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codebase Caverns — ComfyUI Architecture Adventure</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A prestige-driven architecture adventure game. Discover problems, learn patterns, make decisions, and watch the consequences unfold."
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"name": "@comfyorg/architecture-adventure",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build && tsx scripts/inline-build.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:docs",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "vite"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"command": "tsc --noEmit && vite build --config apps/architecture-adventure/vite.config.ts && tsx apps/architecture-adventure/scripts/inline-build.ts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "vite preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const distDir = join(import.meta.dirname, '..', 'dist')
|
||||
const htmlPath = join(distDir, 'index.html')
|
||||
|
||||
let html = readFileSync(htmlPath, 'utf-8')
|
||||
|
||||
const assetsDir = join(distDir, 'assets')
|
||||
if (existsSync(assetsDir)) {
|
||||
const assets = readdirSync(assetsDir)
|
||||
|
||||
// Inline CSS files
|
||||
for (const file of assets) {
|
||||
if (file.endsWith('.css')) {
|
||||
const css = readFileSync(join(assetsDir, file), 'utf-8')
|
||||
html = html.replace(
|
||||
new RegExp(`<link[^>]*href="[./]*assets/${file}"[^>]*>`),
|
||||
`<style>${css}</style>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Inline JS files
|
||||
for (const file of assets) {
|
||||
if (file.endsWith('.js')) {
|
||||
const js = readFileSync(join(assetsDir, file), 'utf-8')
|
||||
html = html.replace(
|
||||
new RegExp(`<script[^>]*src="[./]*assets/${file}"[^>]*></script>`),
|
||||
`<script type="module">${js}</script>`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(htmlPath, html)
|
||||
|
||||
const sizeKB = (Buffer.byteLength(html) / 1024).toFixed(1)
|
||||
console.warn(`Single-file build complete: ${htmlPath} (${sizeKB} KB)`)
|
||||
@@ -1,476 +0,0 @@
|
||||
import type { ChallengeDefinition } from '@/types'
|
||||
|
||||
const GH = 'https://github.com/Comfy-Org/ComfyUI_frontend/blob/main'
|
||||
|
||||
export const challenges: Record<string, ChallengeDefinition> = {
|
||||
'circular-dependency': {
|
||||
id: 'circular-dependency',
|
||||
roomId: 'components',
|
||||
title: 'The Circular Dependency',
|
||||
tier: 1,
|
||||
description:
|
||||
'A tangled knot blocks the corridor ahead. Subgraph extends LGraph, ' +
|
||||
'but LGraph creates and manages Subgraph instances. The circular import ' +
|
||||
'forces order-dependent barrel exports and makes testing impossible in isolation. ' +
|
||||
'How do you untangle it?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['composition'],
|
||||
docLink: {
|
||||
label: 'Entity Problems: Circular Dependencies',
|
||||
url: `${GH}/docs/architecture/entity-problems.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Composition over inheritance',
|
||||
hint: 'A subgraph IS a graph \u2014 just a node with a SubgraphStructure component. ECS eliminates class inheritance entirely.',
|
||||
icon: 'components-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The circular dependency dissolves. Under graph unification, a subgraph is just a node carrying a SubgraphStructure component in a flat World. No inheritance, no special cases.',
|
||||
tagsGranted: ['composition'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Barrel file reordering',
|
||||
hint: 'Rearrange exports so the cycle resolves at module load time.',
|
||||
icon: 'components-b',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The imports stop crashing... for now. But the underlying coupling remains, and any new file touching both classes risks reviving the cycle.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Factory injection',
|
||||
hint: 'Pass a graph factory function to break the static import cycle.',
|
||||
icon: 'components-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
"The factory breaks the import cycle cleanly. It's a pragmatic fix, though the classes remain tightly coupled at runtime.",
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'scattered-mutations': {
|
||||
id: 'scattered-mutations',
|
||||
roomId: 'stores',
|
||||
title: 'The Scattered Mutations',
|
||||
tier: 1,
|
||||
description:
|
||||
'Deep in the vaults, you find a fragile counter: graph._version++. ' +
|
||||
'It appears in 19 locations across 7 files \u2014 LGraph.ts (5 sites), ' +
|
||||
'LGraphNode.ts (8 sites), LGraphCanvas.ts (2 sites), BaseWidget.ts, SubgraphInput.ts, ' +
|
||||
'SubgraphInputNode.ts, SubgraphOutput.ts. ' +
|
||||
'Change tracking depends on this scattered increment. One missed site means silent data loss.',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['centralized-mutations'],
|
||||
docLink: {
|
||||
label: 'Migration Plan: Phase 0a',
|
||||
url: `${GH}/docs/architecture/ecs-migration-plan.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Centralize into graph.incrementVersion()',
|
||||
hint: 'Route all 19 sites through a single method. Phase 0a of the migration plan.',
|
||||
icon: 'stores-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'All 19 scattered increments now flow through one method. Change tracking becomes auditable, and the VersionSystem has a single hook point.',
|
||||
tagsGranted: ['centralized-mutations'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Add a JavaScript Proxy',
|
||||
hint: 'Intercept all writes to _version automatically.',
|
||||
icon: 'stores-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'The Proxy catches mutations, but adds runtime overhead and makes debugging opaque. The scattered sites remain in the code.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Leave it as-is',
|
||||
hint: "It works. Don't touch it.",
|
||||
icon: 'stores-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The team breathes a sigh of relief... until the next silent data loss bug from a missed increment site.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'migration-question': {
|
||||
id: 'migration-question',
|
||||
roomId: 'services',
|
||||
title: 'The Migration Question',
|
||||
tier: 1,
|
||||
description:
|
||||
'A fork in the corridor. The legacy litegraph engine works \u2014 thousands of users ' +
|
||||
'depend on it daily. But the architecture docs describe a better future: ECS with ' +
|
||||
'branded types, pure systems, and a World registry. ' +
|
||||
'How do you get from here to there without breaking production?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['incremental-migration'],
|
||||
docLink: {
|
||||
label: 'ECS Migration Plan',
|
||||
url: `${GH}/docs/architecture/ecs-migration-plan.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: '5-phase incremental plan',
|
||||
hint: 'Foundation \u2192 Types \u2192 Bridge \u2192 Systems \u2192 Legacy Removal. Each phase is independently shippable.',
|
||||
icon: 'services-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The team maps out five phases, each independently testable and shippable. Old and new coexist during transition. Production never breaks.',
|
||||
tagsGranted: ['incremental-migration'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Big bang rewrite',
|
||||
hint: 'Freeze features, rewrite everything in parallel, swap when ready.',
|
||||
icon: 'services-b',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'Feature freeze begins. Weeks pass. The rewrite grows scope. Morale plummets. The old codebase drifts further from the new one.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Strangler fig pattern',
|
||||
hint: 'Build new ECS beside old code, migrate consumers one by one.',
|
||||
icon: 'services-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'A solid pattern. The new system grows organically around the old, though without a phased plan the migration lacks clear milestones.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'god-object-dilemma': {
|
||||
id: 'god-object-dilemma',
|
||||
roomId: 'litegraph',
|
||||
title: 'The God Object Dilemma',
|
||||
tier: 2,
|
||||
description:
|
||||
'LGraphCanvas looms before you: ~9,100 lines of rendering, ' +
|
||||
'input handling, selection, context menus, undo/redo, and more. LGraphNode ' +
|
||||
'adds ~4,300 lines with ~539 method/property definitions mixing rendering, ' +
|
||||
'serialization, connectivity, execution, layout, and state management. ' +
|
||||
"These god objects are the root of most architectural pain. What's your approach?",
|
||||
recommended: 'B',
|
||||
tagsGranted: ['responsibility-extraction'],
|
||||
docLink: {
|
||||
label: 'Entity Problems: God Objects',
|
||||
url: `${GH}/docs/architecture/entity-problems.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Rewrite from scratch',
|
||||
hint: 'Tear it all down and rebuild with clean architecture from day one.',
|
||||
icon: 'litegraph-a',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The rewrite begins heroically... and stalls at month three. The team burns out reimplementing edge cases the god objects handled implicitly.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Extract incrementally',
|
||||
hint: 'Peel responsibilities into focused modules one at a time. Position first, then connectivity, then rendering.',
|
||||
icon: 'litegraph-b',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
"Position extraction lands first (it's already in LayoutStore). Then connectivity. Each extraction is a small, testable PR. The god objects shrink steadily.",
|
||||
tagsGranted: ['responsibility-extraction'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Add a facade layer',
|
||||
hint: 'Wrap the god objects with a clean API without changing internals.',
|
||||
icon: 'litegraph-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'The facade provides a nicer API, but the complexity still lives behind it. New features still require diving into the god objects.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'id-crossroads': {
|
||||
id: 'id-crossroads',
|
||||
roomId: 'ecs',
|
||||
title: 'The ID Crossroads',
|
||||
tier: 2,
|
||||
description:
|
||||
'The blueprints show a problem: NodeId is typed as number | string. ' +
|
||||
'Nothing prevents passing a LinkId where a NodeId is expected. ' +
|
||||
'Widgets are identified by name + parent node (fragile lookup). ' +
|
||||
'Slots are identified by array index (breaks when reordered). ' +
|
||||
'The six entity kinds \u2014 Node, Link, Widget, Slot, Reroute, Group \u2014 all ' +
|
||||
'share the same untyped ID space. How do you bring type safety to this ID chaos?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['branded-types'],
|
||||
docLink: {
|
||||
label: 'ECS Target Architecture: Entity IDs',
|
||||
url: `${GH}/docs/architecture/ecs-target-architecture.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Branded types with cast helpers',
|
||||
hint: "type NodeEntityId = number & { __brand: 'NodeEntityId' } \u2014 compile-time safety, zero runtime cost.",
|
||||
icon: 'ecs-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The compiler now catches cross-kind ID bugs. Cast helpers at system boundaries (asNodeEntityId()) keep the ergonomics clean. Phase 1a complete.',
|
||||
tagsGranted: ['branded-types'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'String prefixes at runtime',
|
||||
hint: '"node:42", "link:7" \u2014 parse and validate at every usage site.',
|
||||
icon: 'ecs-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'Runtime checks catch some bugs, but parsing overhead spreads everywhere. And someone will forget the prefix check in a hot path.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Keep plain numbers',
|
||||
hint: 'Just be careful. Document which IDs are which.',
|
||||
icon: 'ecs-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The next developer passes a LinkId to a node lookup. The silent failure takes two days to debug in production.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'widget-promotion': {
|
||||
id: 'widget-promotion',
|
||||
roomId: 'subgraph',
|
||||
title: 'The Widget Promotion Decision',
|
||||
tier: 2,
|
||||
description:
|
||||
'A user right-clicks a widget inside a subgraph and selects "Promote to parent." ' +
|
||||
'Today this requires three layers: PromotionStore, PromotedWidgetViewManager, ' +
|
||||
'and PromotedWidgetView \u2014 a parallel state system that duplicates what ' +
|
||||
'the type-to-widget mapping already does for normal inputs. ' +
|
||||
'Two candidates for the ECS future. The team must decide before Phase 3 solidifies.',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['typed-contracts'],
|
||||
docLink: {
|
||||
label: 'Subgraph Boundaries: Widget Promotion',
|
||||
url: `${GH}/docs/architecture/subgraph-boundaries-and-promotion.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Connections-only: promotion = adding a typed input',
|
||||
hint: 'Promote a widget by adding an interface input. The type\u2192widget mapping creates the widget automatically. No new concepts.',
|
||||
icon: 'subgraph-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'PromotionStore, ViewManager, and PromotedWidgetView are eliminated entirely. Promotion becomes an operation on the subgraph\u2019s function signature. The existing slot, link, and widget infrastructure handles everything.',
|
||||
tagsGranted: ['typed-contracts'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Simplified component promotion',
|
||||
hint: 'A WidgetPromotion component on widget entities. Removes ViewManager but preserves promotion as a distinct concept.',
|
||||
icon: 'subgraph-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'The ViewManager and proxy reconciliation are gone, but promotion remains a separate concept from connection. Shared subgraph instances face an open question: which source widget is authoritative?',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Keep the current three-layer system',
|
||||
hint: 'PromotionStore + ViewManager + PromotedWidgetView. It works today.',
|
||||
icon: 'subgraph-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The parallel state system persists. Every promoted widget is a shadow copy reconciled by a virtual DOM-like diffing layer. The ECS migration must work around it indefinitely.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'render-time-mutation': {
|
||||
id: 'render-time-mutation',
|
||||
roomId: 'renderer',
|
||||
title: 'The Render-Time Mutation',
|
||||
tier: 2,
|
||||
description:
|
||||
'Alarms sound. The render pipeline has a critical flaw: drawNode() calls ' +
|
||||
'_setConcreteSlots() and arrange() during the render pass. ' +
|
||||
'The render phase mutates state, making draw order affect layout. ' +
|
||||
"Node A's position depends on whether Node B was drawn first. " +
|
||||
'How do you fix the pipeline?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['phase-separation'],
|
||||
docLink: {
|
||||
label: 'Entity Problems: Render-Time Mutations',
|
||||
url: `${GH}/docs/architecture/entity-problems.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Separate update and render phases',
|
||||
hint: 'Compute all layout in an update pass, then render as a pure read-only pass. Matches the ECS system pipeline.',
|
||||
icon: 'renderer-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The pipeline becomes: Input \u2192 Update (layout, connectivity) \u2192 Render (read-only). Draw order no longer matters. Bugs vanish.',
|
||||
tagsGranted: ['phase-separation'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Dirty flags and deferred render',
|
||||
hint: 'Mark mutated nodes dirty, skip them, re-render next frame.',
|
||||
icon: 'renderer-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
"Dirty flags reduce the worst symptoms, but the render pass still has permission to mutate. It's a band-aid on an architectural wound.",
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'collaboration-protocol': {
|
||||
id: 'collaboration-protocol',
|
||||
roomId: 'composables',
|
||||
title: 'The Collaboration Protocol',
|
||||
tier: 3,
|
||||
description:
|
||||
'A request arrives: multiple users want to edit the same workflow simultaneously. ' +
|
||||
'The layoutStore already extracts position data from litegraph entities. ' +
|
||||
'But how do you synchronize positions across users without conflicts?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['crdt-sync'],
|
||||
docLink: {
|
||||
label: 'Proto-ECS Stores: LayoutStore',
|
||||
url: `${GH}/docs/architecture/proto-ecs-stores.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Y.js CRDTs',
|
||||
hint: 'Conflict-free replicated data types. Merge without coordination. Already proven at scale.',
|
||||
icon: 'composables-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'Y.js CRDT maps back the layout store. Concurrent edits merge automatically. ADR 0003 is realized. The collaboration future is here.',
|
||||
tagsGranted: ['crdt-sync'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Polling-based sync',
|
||||
hint: 'Fetch full state every few seconds, merge manually, hope for the best.',
|
||||
icon: 'composables-b',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'Polling creates a flickering, laggy experience. Two users move the same node and one edit is silently lost. Support tickets pile up.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Skip collaboration for now',
|
||||
hint: 'Single-user editing only. Focus on other priorities.',
|
||||
icon: 'composables-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'A pragmatic choice. The team focuses elsewhere. But the cloud product team is not happy about the delay.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'mutation-gateway': {
|
||||
id: 'mutation-gateway',
|
||||
roomId: 'sidepanel',
|
||||
title: 'The Mutation Gateway',
|
||||
tier: 3,
|
||||
description:
|
||||
"A heated debate blocks the forge entrance. One faction argues the World's imperative " +
|
||||
'API (world.setComponent()) conflicts with the command pattern requirement ' +
|
||||
'from ADR 0003. Another faction says commands and the World serve different layers. ' +
|
||||
'How should external callers mutate the World?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['command-layer'],
|
||||
docLink: {
|
||||
label: 'World API and Command Layer',
|
||||
url: `${GH}/docs/architecture/ecs-world-command-api.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Commands as intent; systems as handlers; World as store',
|
||||
hint: 'Caller \u2192 Command \u2192 System \u2192 World \u2192 Y.js. Commands are serializable. The World\u2019s imperative API is internal, called only by systems inside transactions.',
|
||||
icon: 'sidepanel-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The layering clicks. Commands are serializable intent. Systems are command handlers. The World is the store \u2014 its imperative API is internal, just like Redux\u2019s state mutations inside reducers. ADR 0003 and ADR 0008 are complementary layers.',
|
||||
tagsGranted: ['command-layer'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Make World.setComponent() itself serializable',
|
||||
hint: 'Log every World mutation as a serializable operation. The World IS the command system.',
|
||||
icon: 'sidepanel-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'This conflates the store with the command layer. Every internal implementation detail becomes part of the public API. Batch operations like Paste become dozens of logged mutations instead of one intent.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Skip commands \u2014 let callers mutate directly',
|
||||
hint: 'External code calls world.setComponent() directly. Simpler. No ceremony.',
|
||||
icon: 'sidepanel-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'Without a command layer, there is no undo/redo log, no replay, no CRDT sync, and no way to audit what changed. Every caller becomes responsible for transaction management.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { rooms } from './rooms'
|
||||
|
||||
interface GraphEdge {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export const edges: GraphEdge[] = Object.values(rooms).flatMap((room) =>
|
||||
room.connections.map((conn) => ({
|
||||
from: room.id,
|
||||
to: conn.targetRoomId
|
||||
}))
|
||||
)
|
||||
@@ -1,194 +0,0 @@
|
||||
import type {
|
||||
ChallengeRating,
|
||||
NarrativeBridge,
|
||||
NarrativeSection,
|
||||
NarrativeSentence
|
||||
} from '@/types'
|
||||
|
||||
const sentences: NarrativeSentence[] = [
|
||||
{
|
||||
challengeId: 'circular-dependency',
|
||||
good: 'The circular dependency between Subgraph and LGraph dissolved completely. Composition replaced inheritance, and the flat World made special cases unnecessary.',
|
||||
ok: 'A factory injection broke the import cycle, but the classes remain coupled at runtime. The next refactor will revisit this tension.',
|
||||
bad: 'The circular dependency was papered over with barrel file reordering. It lurks beneath the surface, waiting for the next import to revive the cycle.'
|
||||
},
|
||||
{
|
||||
challengeId: 'scattered-mutations',
|
||||
good: 'All 19 scattered version increments were centralized into a single auditable method. Change tracking became reliable overnight.',
|
||||
ok: 'A JavaScript Proxy intercepts version mutations, but the scattered increment sites remain in the code. Debugging has become more opaque.',
|
||||
bad: 'The 19 scattered graph._version++ sites were left untouched. Silent data loss continues to haunt the team with every missed increment.'
|
||||
},
|
||||
{
|
||||
challengeId: 'migration-question',
|
||||
good: 'A 5-phase incremental migration plan was adopted. Each phase ships independently, and production never breaks during the transition.',
|
||||
ok: 'The strangler fig pattern lets new ECS code grow beside the old, but without clear milestones the migration drifts without a timeline.',
|
||||
bad: 'A big-bang rewrite was attempted. Feature freeze dragged on for months, morale collapsed, and the old codebase drifted beyond reconciliation.'
|
||||
},
|
||||
{
|
||||
challengeId: 'god-object-dilemma',
|
||||
good: 'The god objects are being dismantled incrementally. Position extraction shipped first, then connectivity. Each PR is small and testable.',
|
||||
ok: 'A facade wraps the god objects with a cleaner API, but the 9,100-line monolith still lurks behind it. New features still require diving in.',
|
||||
bad: 'The heroic rewrite stalled at month three. The team burned out reimplementing edge cases that the god objects handled implicitly.'
|
||||
},
|
||||
{
|
||||
challengeId: 'id-crossroads',
|
||||
good: 'Branded entity IDs now catch cross-kind bugs at compile time. Cast helpers at system boundaries keep ergonomics clean.',
|
||||
ok: 'Runtime string prefixes catch some ID mix-ups, but parsing overhead spreads everywhere and hot-path checks are occasionally forgotten.',
|
||||
bad: 'Plain untyped numbers remain the norm. A LinkId passed to a node lookup caused a silent failure that took two days to debug.'
|
||||
},
|
||||
{
|
||||
challengeId: 'widget-promotion',
|
||||
good: 'Widget promotion was unified with the connection system. Adding a typed interface input is all it takes \u2014 no parallel state, no shadow copies.',
|
||||
ok: 'A simplified WidgetPromotion component replaced the ViewManager, but promotion remains a concept separate from connections.',
|
||||
bad: 'The three-layer promotion system persists. Every promoted widget is a shadow copy reconciled by a diffing layer the ECS must work around.'
|
||||
},
|
||||
{
|
||||
challengeId: 'render-time-mutation',
|
||||
good: 'Update and render phases are now fully separated. The render pass is read-only, and draw order no longer affects layout.',
|
||||
ok: 'Dirty flags reduced the worst render-time mutation symptoms, but the render pass still has permission to mutate state.',
|
||||
bad: 'Render-time mutations continue unchecked. Node positions depend on draw order, and every new node type risks layout-dependent bugs.'
|
||||
},
|
||||
{
|
||||
challengeId: 'collaboration-protocol',
|
||||
good: 'Y.js CRDTs back the layout store. Concurrent edits merge automatically, and real-time collaboration is now a reality.',
|
||||
ok: 'Collaboration was deferred to focus on other priorities. The cloud product team awaits, but the architecture is ready when the time comes.',
|
||||
bad: 'Polling-based sync was implemented. Users experience flickering, lag, and silently lost edits. Support tickets pile up.'
|
||||
},
|
||||
{
|
||||
challengeId: 'mutation-gateway',
|
||||
good: 'The command layer is in place: serializable intent flows through systems into the World. Undo/redo, replay, and CRDT sync all work.',
|
||||
ok: 'World mutations are logged as serializable operations, but the store and command layer are conflated. Batch operations produce excessive noise.',
|
||||
bad: 'Without a command layer, callers mutate the World directly. There is no undo/redo, no replay, and no audit trail.'
|
||||
}
|
||||
]
|
||||
|
||||
const sections: NarrativeSection[] = [
|
||||
{
|
||||
id: 'legacy',
|
||||
title: 'The Legacy',
|
||||
challengeIds: [
|
||||
'circular-dependency',
|
||||
'god-object-dilemma',
|
||||
'scattered-mutations'
|
||||
],
|
||||
introByTone: {
|
||||
optimistic:
|
||||
'The legacy codebase has been thoroughly understood and its worst patterns addressed.',
|
||||
mixed:
|
||||
'Some legacy patterns were addressed, while others remain embedded in the architecture.',
|
||||
pessimistic:
|
||||
'The legacy codebase retains most of its original pain points, resisting transformation.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'architecture',
|
||||
title: 'The Architecture',
|
||||
challengeIds: ['id-crossroads', 'mutation-gateway', 'render-time-mutation'],
|
||||
introByTone: {
|
||||
optimistic:
|
||||
'The new architecture stands on solid foundations \u2014 type-safe, layered, and deterministic.',
|
||||
mixed:
|
||||
'The architectural vision is partially realized. Some foundations are strong, others compromise.',
|
||||
pessimistic:
|
||||
'The architectural redesign never fully materialized. Old and new patterns clash at every boundary.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'future',
|
||||
title: 'The Future',
|
||||
challengeIds: [
|
||||
'migration-question',
|
||||
'collaboration-protocol',
|
||||
'widget-promotion'
|
||||
],
|
||||
introByTone: {
|
||||
optimistic:
|
||||
'The path forward is clear. Migration proceeds in phases, collaboration is live, and the ECS world hums with clean data.',
|
||||
mixed:
|
||||
'The future is promising but uncertain. Some migration paths are clear while others remain open questions.',
|
||||
pessimistic:
|
||||
'The migration stalls. Technical debt compounds, and the team struggles to chart a path through the complexity.'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const bridges: NarrativeBridge[] = [
|
||||
{
|
||||
fromSectionId: 'legacy',
|
||||
toSectionId: 'architecture',
|
||||
byTone: {
|
||||
optimistic:
|
||||
'With the legacy pain points addressed, the team turned to building the new architecture with confidence.',
|
||||
mixed:
|
||||
'Despite unresolved legacy issues, the team pressed forward with architectural decisions.',
|
||||
pessimistic:
|
||||
'The unaddressed legacy problems cast a long shadow over every architectural decision that followed.'
|
||||
}
|
||||
},
|
||||
{
|
||||
fromSectionId: 'architecture',
|
||||
toSectionId: 'future',
|
||||
byTone: {
|
||||
optimistic:
|
||||
'The solid architectural foundations enabled ambitious plans for migration and collaboration.',
|
||||
mixed:
|
||||
'With a mixed architectural foundation, the team faced the future with cautious optimism.',
|
||||
pessimistic:
|
||||
'Weak architectural foundations made every forward-looking decision feel like building on sand.'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
function getSentenceMap(): Map<string, NarrativeSentence> {
|
||||
return new Map(sentences.map((s) => [s.challengeId, s]))
|
||||
}
|
||||
|
||||
type Tone = 'optimistic' | 'mixed' | 'pessimistic'
|
||||
|
||||
function sectionTone(
|
||||
results: Record<string, { rating: ChallengeRating }>,
|
||||
challengeIds: string[]
|
||||
): Tone {
|
||||
const ratings = challengeIds.map((id) => results[id]?.rating).filter(Boolean)
|
||||
if (ratings.length === 0) return 'mixed'
|
||||
|
||||
const goodCount = ratings.filter((r) => r === 'good').length
|
||||
const badCount = ratings.filter((r) => r === 'bad').length
|
||||
|
||||
if (goodCount >= ratings.length * 0.6) return 'optimistic'
|
||||
if (badCount >= ratings.length * 0.6) return 'pessimistic'
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
export function buildNarrativeSummary(
|
||||
results: Record<string, { rating: ChallengeRating }>
|
||||
): string {
|
||||
const sentenceMap = getSentenceMap()
|
||||
const parts: string[] = []
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i]
|
||||
const tone = sectionTone(results, section.challengeIds)
|
||||
|
||||
parts.push(section.introByTone[tone])
|
||||
|
||||
for (const challengeId of section.challengeIds) {
|
||||
const sentence = sentenceMap.get(challengeId)
|
||||
const result = results[challengeId]
|
||||
if (sentence && result) {
|
||||
parts.push(sentence[result.rating])
|
||||
}
|
||||
}
|
||||
|
||||
if (i < bridges.length) {
|
||||
const bridge = bridges[i]
|
||||
const nextSection = sections[i + 1]
|
||||
const bridgeTone = nextSection
|
||||
? sectionTone(results, nextSection.challengeIds)
|
||||
: tone
|
||||
parts.push(bridge.byTone[bridgeTone])
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
import type { RoomDefinition } from '@/types'
|
||||
|
||||
const GH = 'https://github.com/Comfy-Org/ComfyUI_frontend/blob/main'
|
||||
|
||||
export const rooms: Record<string, RoomDefinition> = {
|
||||
entry: {
|
||||
id: 'entry',
|
||||
title: 'The Entry Point',
|
||||
layer: 'src/main.ts',
|
||||
discoveryDescription:
|
||||
`You stand at ${GH}/src/main.ts, the entry point of the ComfyUI frontend. ` +
|
||||
'The air hums with the bootstrapping of a Vue 3 application. Pinia stores ' +
|
||||
'initialize around you, the router unfurls paths into the distance, and ' +
|
||||
'i18n translations whisper in dozens of languages. ' +
|
||||
'Three corridors stretch ahead, each leading deeper into the architecture. ' +
|
||||
'Somewhere in this codebase, god objects lurk, mutations scatter in the shadows, ' +
|
||||
'and a grand migration awaits your decisions.',
|
||||
solutionDescription: '',
|
||||
prerequisites: [],
|
||||
artifacts: [],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'components',
|
||||
label: 'Enter the Component Gallery',
|
||||
hint: 'Presentation Layer'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'stores',
|
||||
label: 'Descend into the Store Vaults',
|
||||
hint: 'State Management'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'services',
|
||||
label: 'Follow the wires to Services',
|
||||
hint: 'Business Logic'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
components: {
|
||||
id: 'components',
|
||||
title: 'The Component Gallery',
|
||||
layer: 'Presentation',
|
||||
discoveryDescription:
|
||||
'Vast halls lined with Vue Single File Components. GraphView.vue dominates the center \u2014 ' +
|
||||
'the main canvas workspace where nodes are wired together. But a tangled knot blocks ' +
|
||||
'the corridor ahead: Subgraph extends LGraph, and LGraph creates Subgraph instances. ' +
|
||||
'The circular import forces order-dependent barrel exports and makes testing impossible ' +
|
||||
'in isolation.',
|
||||
solutionDescription:
|
||||
'The circular dependency dissolves when you realize a subgraph is just a node ' +
|
||||
'carrying a SubgraphStructure component. Composition replaces inheritance, and the ' +
|
||||
'flat World eliminates special cases entirely.',
|
||||
prerequisites: [],
|
||||
artifacts: [
|
||||
{ name: 'GraphView.vue', type: 'Component', icon: 'graphview' }
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'litegraph',
|
||||
label: 'Inspect the Canvas',
|
||||
hint: 'Litegraph Engine'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'sidepanel',
|
||||
label: 'Enter the Command Forge',
|
||||
hint: 'Commands & Intent'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'circular-dependency'
|
||||
},
|
||||
|
||||
stores: {
|
||||
id: 'stores',
|
||||
title: 'The Store Vaults',
|
||||
layer: 'State',
|
||||
discoveryDescription:
|
||||
'Sixty Pinia stores line the walls like vault doors, each guarding a domain of reactive state. ' +
|
||||
'Deep in the vaults, you find a fragile counter: graph._version++. It appears in 19 locations ' +
|
||||
'across 7 files \u2014 LGraph.ts, LGraphNode.ts, LGraphCanvas.ts, BaseWidget.ts, SubgraphInput.ts, ' +
|
||||
'SubgraphInputNode.ts, SubgraphOutput.ts. Change tracking depends on this scattered increment. ' +
|
||||
'One missed site means silent data loss.',
|
||||
solutionDescription:
|
||||
'Centralizing all 19 increment sites into a single graph.incrementVersion() method makes ' +
|
||||
'change tracking auditable. The VersionSystem gains a single hook point, and Phase 0a ' +
|
||||
'of the migration plan is complete.',
|
||||
prerequisites: [],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'widgetValueStore.ts',
|
||||
type: 'Proto-ECS Store',
|
||||
icon: 'widgetvaluestore'
|
||||
},
|
||||
{
|
||||
name: 'layoutStore.ts',
|
||||
type: 'Proto-ECS Store',
|
||||
icon: 'layoutstore'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Examine the ECS Blueprints',
|
||||
hint: 'Entity-Component-System'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'renderer',
|
||||
label: 'Visit the Renderer',
|
||||
hint: 'Canvas & Layout'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'scattered-mutations'
|
||||
},
|
||||
|
||||
services: {
|
||||
id: 'services',
|
||||
title: 'The Service Corridors',
|
||||
layer: 'Services',
|
||||
discoveryDescription:
|
||||
'Clean corridors of orchestration logic. litegraphService.ts manages graph creation and ' +
|
||||
'serialization. extensionService.ts loads third-party extensions. But a fork in the corridor ' +
|
||||
'reveals the core tension: the legacy litegraph engine works \u2014 thousands of users depend on ' +
|
||||
'it daily \u2014 yet the architecture docs describe a better future with ECS, branded types, and ' +
|
||||
'a World registry. How do you get from here to there without breaking production?',
|
||||
solutionDescription:
|
||||
'A 5-phase incremental migration plan maps the path forward. Each phase is independently ' +
|
||||
'testable and shippable. Old and new coexist during transition. Production never breaks.',
|
||||
prerequisites: [],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'litegraphService.ts',
|
||||
type: 'Service',
|
||||
icon: 'litegraphservice'
|
||||
},
|
||||
{
|
||||
name: 'Extension Migration Guide',
|
||||
type: 'Design Pattern',
|
||||
icon: 'extension-migration'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'composables',
|
||||
label: 'Follow the Composables',
|
||||
hint: 'Reusable Logic Hooks'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'migration-question'
|
||||
},
|
||||
|
||||
litegraph: {
|
||||
id: 'litegraph',
|
||||
title: 'The Litegraph Engine Room',
|
||||
layer: 'Graph Engine',
|
||||
discoveryDescription:
|
||||
"The beating heart of ComfyUI's visual programming. Massive class files loom: " +
|
||||
'LGraphCanvas.ts at ~9,100 lines handles all rendering and interaction, ' +
|
||||
'LGraphNode.ts at ~4,300 lines is the god-object node entity, and ' +
|
||||
'LGraph.ts at ~3,100 lines contains the graph itself. ' +
|
||||
'These god objects are the root of most architectural pain \u2014 circular dependencies, ' +
|
||||
'render-time side effects, and scattered mutation sites.',
|
||||
solutionDescription:
|
||||
'Incremental extraction peels responsibilities into focused modules one at a time. ' +
|
||||
'Position extraction lands first (already in LayoutStore), then connectivity. ' +
|
||||
'Each extraction is a small, testable PR. The god objects shrink steadily.',
|
||||
prerequisites: ['composition'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'LGraphCanvas.ts',
|
||||
type: 'God Object',
|
||||
icon: 'lgraphcanvas'
|
||||
},
|
||||
{ name: 'LGraphNode.ts', type: 'God Object', icon: 'lgraphnode' }
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Examine the ECS Blueprints',
|
||||
hint: 'The planned future'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'components',
|
||||
label: 'Return to Components',
|
||||
hint: 'Presentation Layer'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'god-object-dilemma'
|
||||
},
|
||||
|
||||
ecs: {
|
||||
id: 'ecs',
|
||||
title: "The ECS Architect's Chamber",
|
||||
layer: 'ECS',
|
||||
discoveryDescription:
|
||||
'Blueprints cover every surface. The Entity-Component-System architecture is taking shape: ' +
|
||||
'six entity kinds \u2014 Node, Link, Widget, Slot, Reroute, Group \u2014 each identified by ' +
|
||||
'untyped IDs. NodeId is typed as number | string. Nothing prevents passing a LinkId where ' +
|
||||
'a NodeId is expected. Widgets are identified by name + parent node (fragile lookup). ' +
|
||||
'Slots are identified by array index (breaks when reordered). The six entity kinds all ' +
|
||||
'share the same untyped ID space.',
|
||||
solutionDescription:
|
||||
'Branded types with cast helpers bring compile-time safety at zero runtime cost. ' +
|
||||
'type NodeEntityId = number & { __brand: "NodeEntityId" }. Cast helpers at system ' +
|
||||
'boundaries keep ergonomics clean. Phase 1a is complete.',
|
||||
prerequisites: ['centralized-mutations'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'World Registry',
|
||||
type: 'ECS Core',
|
||||
icon: 'world-registry'
|
||||
},
|
||||
{
|
||||
name: 'Branded Entity IDs',
|
||||
type: 'Type Safety',
|
||||
icon: 'branded-ids'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'subgraph',
|
||||
label: 'Descend into the Subgraph Depths',
|
||||
hint: 'Boundaries & Promotion'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'renderer',
|
||||
label: 'Visit the Renderer',
|
||||
hint: 'Canvas & Layout'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'id-crossroads'
|
||||
},
|
||||
|
||||
sidepanel: {
|
||||
id: 'sidepanel',
|
||||
title: 'The Command Forge',
|
||||
layer: 'Commands & Intent',
|
||||
discoveryDescription:
|
||||
'You enter a forge where raw user intent is shaped into structured commands. ' +
|
||||
"A heated debate blocks the forge entrance. One faction argues the World's imperative " +
|
||||
'API (world.setComponent()) conflicts with the command pattern requirement from ADR 0003. ' +
|
||||
'Another faction says commands and the World serve different layers. ' +
|
||||
'How should external callers mutate the World?',
|
||||
solutionDescription:
|
||||
'Commands are serializable intent. Systems are command handlers. The World is the store \u2014 ' +
|
||||
"its imperative API is internal, just like Redux's state mutations inside reducers. " +
|
||||
'ADR 0003 and ADR 0008 are complementary layers.',
|
||||
prerequisites: ['branded-types'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'CommandExecutor',
|
||||
type: 'ECS Core',
|
||||
icon: 'command-executor'
|
||||
},
|
||||
{
|
||||
name: 'Command Interface',
|
||||
type: 'Design Pattern',
|
||||
icon: 'command-interface'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'components',
|
||||
label: 'Return to the Component Gallery',
|
||||
hint: 'Presentation Layer'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'stores',
|
||||
label: 'Descend into the Store Vaults',
|
||||
hint: 'State Management'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'mutation-gateway'
|
||||
},
|
||||
|
||||
subgraph: {
|
||||
id: 'subgraph',
|
||||
title: 'The Subgraph Depths',
|
||||
layer: 'Graph Boundaries',
|
||||
discoveryDescription:
|
||||
'You descend into nested chambers, each a perfect replica of the one above \u2014 graphs ' +
|
||||
'within graphs within graphs. The current code tells a painful story: Subgraph extends LGraph, ' +
|
||||
'virtual nodes with magic IDs (SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20), and three ' +
|
||||
'layers of indirection at every boundary crossing. Widget promotion requires PromotionStore, ' +
|
||||
'PromotedWidgetViewManager, and PromotedWidgetView \u2014 a parallel state system duplicating ' +
|
||||
'what the type-to-widget mapping already handles.',
|
||||
solutionDescription:
|
||||
"Under graph unification, promotion becomes an operation on the subgraph's function signature. " +
|
||||
'Promote a widget by adding an interface input. The type-to-widget mapping creates the widget ' +
|
||||
'automatically. PromotionStore, ViewManager, and PromotedWidgetView are eliminated entirely.',
|
||||
prerequisites: ['branded-types', 'composition'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'SubgraphStructure',
|
||||
type: 'ECS Component',
|
||||
icon: 'subgraph-structure'
|
||||
},
|
||||
{
|
||||
name: 'Typed Interface Contracts',
|
||||
type: 'Design Pattern',
|
||||
icon: 'typed-contracts'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Return to the ECS Chamber',
|
||||
hint: 'Entity-Component-System'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'litegraph',
|
||||
label: 'Visit the Litegraph Engine Room',
|
||||
hint: 'Graph Engine'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'widget-promotion'
|
||||
},
|
||||
|
||||
renderer: {
|
||||
id: 'renderer',
|
||||
title: 'The Renderer Overlook',
|
||||
layer: 'Renderer',
|
||||
discoveryDescription:
|
||||
'From here you can see the entire canvas rendering pipeline. But alarms sound: ' +
|
||||
'drawNode() calls _setConcreteSlots() and arrange() during the render pass. ' +
|
||||
'The render phase mutates state, making draw order affect layout. ' +
|
||||
"Node A's position depends on whether Node B was drawn first. " +
|
||||
'This is a critical pipeline flaw.',
|
||||
solutionDescription:
|
||||
'Separating update and render phases fixes the pipeline: Input \u2192 Update (layout, connectivity) ' +
|
||||
'\u2192 Render (read-only). Draw order no longer matters. The ECS system pipeline enforces ' +
|
||||
'this separation structurally.',
|
||||
prerequisites: ['responsibility-extraction'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'QuadTree Spatial Index',
|
||||
type: 'Data Structure',
|
||||
icon: 'quadtree'
|
||||
},
|
||||
{
|
||||
name: 'Y.js CRDT Layout',
|
||||
type: 'Collaboration',
|
||||
icon: 'yjs-crdt'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Examine the ECS Blueprints',
|
||||
hint: 'Entity-Component-System'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'render-time-mutation'
|
||||
},
|
||||
|
||||
composables: {
|
||||
id: 'composables',
|
||||
title: 'The Composables Workshop',
|
||||
layer: 'Composables',
|
||||
discoveryDescription:
|
||||
'Hooks hang from the walls, each a reusable piece of Vue composition logic. ' +
|
||||
'useCoreCommands.ts is the largest at 42KB \u2014 an orchestrator binding keyboard ' +
|
||||
'shortcuts to application commands. A request arrives: multiple users want to edit ' +
|
||||
'the same workflow simultaneously. The layoutStore already extracts position data ' +
|
||||
'from litegraph entities. But how do you synchronize positions across users without conflicts?',
|
||||
solutionDescription:
|
||||
'Y.js CRDTs back the layout store. Concurrent edits merge automatically without coordination. ' +
|
||||
'ADR 0003 is realized. The collaboration future is here.',
|
||||
prerequisites: ['incremental-migration'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'useCoreCommands.ts',
|
||||
type: 'Composable',
|
||||
icon: 'usecorecommands'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'stores',
|
||||
label: 'Descend into the Store Vaults',
|
||||
hint: 'State Management'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'collaboration-protocol'
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { SaveState } from '@/types'
|
||||
|
||||
function isRoomDiscovered(roomId: string, save: SaveState): boolean {
|
||||
return save.currentRun.path.includes(roomId)
|
||||
}
|
||||
|
||||
function isChallengeResolved(challengeId: string, save: SaveState): boolean {
|
||||
return challengeId in save.currentRun.resolvedChallenges
|
||||
}
|
||||
|
||||
function countResolvedChallenges(save: SaveState): number {
|
||||
return Object.keys(save.currentRun.resolvedChallenges).length
|
||||
}
|
||||
|
||||
export { countResolvedChallenges, isChallengeResolved, isRoomDiscovered }
|
||||
@@ -1,107 +0,0 @@
|
||||
import type {
|
||||
ChallengeDefinition,
|
||||
ChallengeResult,
|
||||
GamePhase,
|
||||
GameState,
|
||||
SaveState
|
||||
} from '@/types'
|
||||
import { persistSave } from '@/state/gameState'
|
||||
import { grantTags } from '@/state/tags'
|
||||
|
||||
type GameEventHandler = (state: GameState) => void
|
||||
|
||||
let currentState: GameState
|
||||
let listeners: GameEventHandler[] = []
|
||||
|
||||
function initGameState(save: SaveState): void {
|
||||
currentState = {
|
||||
phase: 'exploring',
|
||||
save
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
function subscribe(handler: GameEventHandler): () => void {
|
||||
listeners.push(handler)
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== handler)
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) {
|
||||
listener(currentState)
|
||||
}
|
||||
}
|
||||
|
||||
function transition(phase: GamePhase, saveUpdates?: Partial<SaveState>): void {
|
||||
const newSave = saveUpdates
|
||||
? { ...currentState.save, ...saveUpdates }
|
||||
: currentState.save
|
||||
|
||||
currentState = { phase, save: newSave }
|
||||
persistSave(currentState.save)
|
||||
notify()
|
||||
}
|
||||
|
||||
function enterRoom(roomId: string): void {
|
||||
const run = currentState.save.currentRun
|
||||
const newPath = run.path.includes(roomId) ? run.path : [...run.path, roomId]
|
||||
|
||||
transition('exploring', {
|
||||
currentRun: {
|
||||
...run,
|
||||
currentRoom: roomId,
|
||||
path: newPath
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveChallenge(
|
||||
challenge: ChallengeDefinition,
|
||||
choiceKey: string
|
||||
): void {
|
||||
const choice = challenge.choices.find((c) => c.key === choiceKey)
|
||||
if (!choice) return
|
||||
|
||||
const result: ChallengeResult = {
|
||||
choiceKey,
|
||||
rating: choice.rating,
|
||||
tier: challenge.tier
|
||||
}
|
||||
|
||||
let save = {
|
||||
...currentState.save,
|
||||
currentRun: {
|
||||
...currentState.save.currentRun,
|
||||
resolvedChallenges: {
|
||||
...currentState.save.currentRun.resolvedChallenges,
|
||||
[challenge.id]: result
|
||||
},
|
||||
insightEarned:
|
||||
currentState.save.currentRun.insightEarned + choice.insightReward
|
||||
}
|
||||
}
|
||||
|
||||
save = grantTags(save, challenge.tagsGranted)
|
||||
save = grantTags(save, choice.tagsGranted)
|
||||
|
||||
transition('challenge-resolved', save)
|
||||
}
|
||||
|
||||
function showEnding(): void {
|
||||
transition('ending')
|
||||
}
|
||||
|
||||
function resetForPrestige(newSave: SaveState): void {
|
||||
transition('exploring', newSave)
|
||||
}
|
||||
|
||||
export {
|
||||
enterRoom,
|
||||
initGameState,
|
||||
resetForPrestige,
|
||||
resolveChallenge,
|
||||
showEnding,
|
||||
subscribe
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import './style/theme.css'
|
||||
import './style/layout.css'
|
||||
import './style/hud.css'
|
||||
import './style/room.css'
|
||||
import './style/challenge.css'
|
||||
import './style/sidebar.css'
|
||||
import './style/map.css'
|
||||
import './style/animations.css'
|
||||
|
||||
import { isV1Save, loadSave } from '@/state/gameState'
|
||||
import { enterRoom, initGameState, subscribe } from '@/engine/stateMachine'
|
||||
import { mountApp, render } from '@/ui/renderer'
|
||||
|
||||
function main(): void {
|
||||
if (isV1Save()) {
|
||||
console.warn('Codebase Caverns v1 save detected. Starting fresh for v2.')
|
||||
}
|
||||
|
||||
const save = loadSave()
|
||||
mountApp()
|
||||
initGameState(save)
|
||||
subscribe(render)
|
||||
enterRoom(save.currentRun.currentRoom)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { CurrentRun, Layer, SaveState } from '@/types'
|
||||
|
||||
const STORAGE_KEY = 'codebase-caverns-v2'
|
||||
const SAVE_VERSION = 1
|
||||
|
||||
function createFreshRun(layer: Layer): CurrentRun {
|
||||
return {
|
||||
layer,
|
||||
path: [],
|
||||
resolvedChallenges: {},
|
||||
conceptTags: [],
|
||||
insightEarned: 0,
|
||||
currentRoom: 'entry'
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultSave(): SaveState {
|
||||
return {
|
||||
version: SAVE_VERSION,
|
||||
currentRun: createFreshRun(1),
|
||||
history: [],
|
||||
persistent: {
|
||||
totalInsight: 0,
|
||||
currentLayer: 1,
|
||||
achievements: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSave(): SaveState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return createDefaultSave()
|
||||
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'version' in parsed &&
|
||||
(parsed as SaveState).version === SAVE_VERSION
|
||||
) {
|
||||
return parsed as SaveState
|
||||
}
|
||||
return createDefaultSave()
|
||||
} catch {
|
||||
return createDefaultSave()
|
||||
}
|
||||
}
|
||||
|
||||
function persistSave(save: SaveState): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(save))
|
||||
}
|
||||
|
||||
function clearSave(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function isV1Save(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem('codebase-caverns')
|
||||
return raw !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { clearSave, createFreshRun, isV1Save, loadSave, persistSave }
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Layer, RunRecord, SaveState } from '@/types'
|
||||
import { createFreshRun } from '@/state/gameState'
|
||||
|
||||
function finalizeRun(save: SaveState, narrativeSummary: string): RunRecord {
|
||||
return {
|
||||
layer: save.currentRun.layer,
|
||||
path: save.currentRun.path,
|
||||
challenges: { ...save.currentRun.resolvedChallenges },
|
||||
conceptTags: [...save.currentRun.conceptTags],
|
||||
insightEarned: save.currentRun.insightEarned,
|
||||
narrativeSummary
|
||||
}
|
||||
}
|
||||
|
||||
function canPrestige(save: SaveState): boolean {
|
||||
return save.persistent.currentLayer < 3
|
||||
}
|
||||
|
||||
function prestige(save: SaveState, narrativeSummary: string): SaveState {
|
||||
const record = finalizeRun(save, narrativeSummary)
|
||||
const nextLayer = Math.min(save.persistent.currentLayer + 1, 3) as Layer
|
||||
|
||||
return {
|
||||
...save,
|
||||
currentRun: createFreshRun(nextLayer),
|
||||
history: [...save.history, record],
|
||||
persistent: {
|
||||
...save.persistent,
|
||||
totalInsight:
|
||||
save.persistent.totalInsight + save.currentRun.insightEarned,
|
||||
currentLayer: nextLayer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { canPrestige, prestige }
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { RoomDefinition, SaveState } from '@/types'
|
||||
|
||||
function canEnterRoom(room: RoomDefinition, save: SaveState): boolean {
|
||||
return room.prerequisites.every((tag) =>
|
||||
save.currentRun.conceptTags.includes(tag)
|
||||
)
|
||||
}
|
||||
|
||||
function grantTags(save: SaveState, tags: string[]): SaveState {
|
||||
const newTags = tags.filter((t) => !save.currentRun.conceptTags.includes(t))
|
||||
if (newTags.length === 0) return save
|
||||
|
||||
return {
|
||||
...save,
|
||||
currentRun: {
|
||||
...save.currentRun,
|
||||
conceptTags: [...save.currentRun.conceptTags, ...newTags]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { canEnterRoom, grantTags }
|
||||
@@ -1,46 +0,0 @@
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes unlockPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgb(88 166 255 / 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgb(88 166 255 / 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(88 166 255 / 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nodeUnlock {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.map-node.newly-unlocked circle {
|
||||
animation: unlockPulse 0.6s ease-out;
|
||||
}
|
||||
|
||||
.map-node {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
#challenge-panel {
|
||||
border: 2px solid var(--yellow);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
animation: fadeSlideIn 0.3s ease;
|
||||
}
|
||||
|
||||
#challenge-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#challenge-header {
|
||||
background: rgb(210 153 34 / 0.1);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--yellow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#challenge-header .icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#challenge-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
#challenge-desc {
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#challenge-desc code {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#challenge-desc a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
}
|
||||
|
||||
#challenge-desc a:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
#challenge-choices {
|
||||
padding: 8px 16px 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn {
|
||||
flex: 1;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.challenge-choice-btn:hover {
|
||||
border-color: var(--yellow);
|
||||
background: rgb(210 153 34 / 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-icon-wrap {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-key {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: var(--yellow);
|
||||
color: var(--bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-text {
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#result-banner {
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
display: none;
|
||||
animation: fadeSlideIn 0.3s ease;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#result-banner.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#result-banner.good {
|
||||
border: 1px solid var(--green);
|
||||
background: rgb(63 185 80 / 0.08);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
#result-banner.ok {
|
||||
border: 1px solid var(--yellow);
|
||||
background: rgb(210 153 34 / 0.08);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
#result-banner.bad {
|
||||
border: 1px solid var(--red);
|
||||
background: rgb(248 81 73 / 0.08);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.stat-delta {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-delta.positive {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.stat-delta.negative {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.result-recommended {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgb(88 166 255 / 0.06);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.result-recommended strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.result-doc-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.result-doc-link:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
#hud,
|
||||
.choice-key,
|
||||
.sidebar-header,
|
||||
#room-layer,
|
||||
#challenge-header,
|
||||
#toggle-map,
|
||||
.choice-btn .choice-hint,
|
||||
.challenge-choice-btn .choice-hint {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#hud {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#hud h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#hud-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#restart-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#restart-btn:hover {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
#toggle-map {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#toggle-map:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#hud {
|
||||
padding: 6px 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#hud h1 {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#hud-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#restart-btn,
|
||||
#toggle-map {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
#main {
|
||||
display: flex;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#main {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
#map-dialog {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
color: var(--text);
|
||||
box-shadow: 0 20px 60px rgb(0 0 0 / 0.6);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
overlay 0.2s ease allow-discrete,
|
||||
display 0.2s ease allow-discrete;
|
||||
}
|
||||
|
||||
#map-dialog[open] {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#map-dialog[open] {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
#map-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
overlay 0.2s ease allow-discrete,
|
||||
display 0.2s ease allow-discrete;
|
||||
}
|
||||
|
||||
#map-dialog[open]::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#map-dialog[open]::backdrop {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#map-dialog h3 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.map-node circle {
|
||||
transition:
|
||||
fill 0.3s ease,
|
||||
stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.map-node.locked circle {
|
||||
fill: var(--bg);
|
||||
stroke: var(--border);
|
||||
}
|
||||
|
||||
.map-node.visited circle {
|
||||
fill: var(--surface);
|
||||
stroke: var(--green);
|
||||
}
|
||||
|
||||
.map-node.current circle {
|
||||
fill: var(--accent-dim);
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.map-edge {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.map-label {
|
||||
fill: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.map-title {
|
||||
fill: var(--muted);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.map-badge {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.map-lock {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
#room-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
#room-header h2 {
|
||||
font-size: 26px;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#room-layer {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.room-image {
|
||||
aspect-ratio: 21 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.room-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.room-image.placeholder {
|
||||
background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 50%, #161b22 100%);
|
||||
border-style: dashed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#room-description {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#room-description code {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#room-description a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
}
|
||||
|
||||
#room-description a:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
#room-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.choice-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.choice-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.choice-btn .choice-key {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.choice-btn .choice-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.choice-btn .choice-hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
.sidebar-header {
|
||||
background: var(--surface);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-entry.discovery {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.log-entry.warning {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.log-entry.ending {
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #9ea7b0;
|
||||
--accent: #58a6ff;
|
||||
--accent-dim: #1f6feb33;
|
||||
--green: #3fb950;
|
||||
--yellow: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #bc8cff;
|
||||
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: auto;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// --- Enumerations ---
|
||||
|
||||
type Layer = 1 | 2 | 3
|
||||
|
||||
type ChallengeRating = 'good' | 'ok' | 'bad'
|
||||
|
||||
type GamePhase =
|
||||
| 'exploring'
|
||||
| 'challenge-available'
|
||||
| 'challenge-resolved'
|
||||
| 'ending'
|
||||
| 'prestige'
|
||||
|
||||
// --- Room & Challenge Data ---
|
||||
|
||||
interface RoomConnection {
|
||||
targetRoomId: string
|
||||
label: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface Artifact {
|
||||
name: string
|
||||
type: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface RoomDefinition {
|
||||
id: string
|
||||
title: string
|
||||
layer: string
|
||||
discoveryDescription: string
|
||||
solutionDescription: string
|
||||
prerequisites: string[]
|
||||
artifacts: Artifact[]
|
||||
connections: RoomConnection[]
|
||||
challengeId?: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
interface ChallengeChoice {
|
||||
key: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: string
|
||||
rating: ChallengeRating
|
||||
feedback: string
|
||||
tagsGranted: string[]
|
||||
insightReward: number
|
||||
}
|
||||
|
||||
interface ChallengeDefinition {
|
||||
id: string
|
||||
roomId: string
|
||||
title: string
|
||||
tier: number
|
||||
description: string
|
||||
recommended: string
|
||||
docLink?: { label: string; url: string }
|
||||
tagsGranted: string[]
|
||||
choices: ChallengeChoice[]
|
||||
}
|
||||
|
||||
// --- Narrative ---
|
||||
|
||||
interface NarrativeSentence {
|
||||
challengeId: string
|
||||
good: string
|
||||
ok: string
|
||||
bad: string
|
||||
}
|
||||
|
||||
interface NarrativeSection {
|
||||
id: string
|
||||
title: string
|
||||
challengeIds: string[]
|
||||
introByTone: { optimistic: string; mixed: string; pessimistic: string }
|
||||
}
|
||||
|
||||
interface NarrativeBridge {
|
||||
fromSectionId: string
|
||||
toSectionId: string
|
||||
byTone: { optimistic: string; mixed: string; pessimistic: string }
|
||||
}
|
||||
|
||||
// --- Save State ---
|
||||
|
||||
interface ChallengeResult {
|
||||
choiceKey: string
|
||||
rating: ChallengeRating
|
||||
tier: number
|
||||
}
|
||||
|
||||
interface RunRecord {
|
||||
layer: Layer
|
||||
path: string[]
|
||||
challenges: Record<string, ChallengeResult>
|
||||
conceptTags: string[]
|
||||
insightEarned: number
|
||||
narrativeSummary: string
|
||||
}
|
||||
|
||||
interface CurrentRun {
|
||||
layer: Layer
|
||||
path: string[]
|
||||
resolvedChallenges: Record<string, ChallengeResult>
|
||||
conceptTags: string[]
|
||||
insightEarned: number
|
||||
currentRoom: string
|
||||
}
|
||||
|
||||
interface PersistentState {
|
||||
totalInsight: number
|
||||
currentLayer: Layer
|
||||
achievements: string[]
|
||||
}
|
||||
|
||||
interface SaveState {
|
||||
version: number
|
||||
currentRun: CurrentRun
|
||||
history: RunRecord[]
|
||||
persistent: PersistentState
|
||||
}
|
||||
|
||||
// --- Engine State ---
|
||||
|
||||
interface GameState {
|
||||
phase: GamePhase
|
||||
save: SaveState
|
||||
}
|
||||
|
||||
export type {
|
||||
ChallengeDefinition,
|
||||
ChallengeRating,
|
||||
ChallengeResult,
|
||||
CurrentRun,
|
||||
GamePhase,
|
||||
GameState,
|
||||
Layer,
|
||||
NarrativeBridge,
|
||||
NarrativeSection,
|
||||
NarrativeSentence,
|
||||
RoomDefinition,
|
||||
RunRecord,
|
||||
SaveState
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { ChallengeDefinition, GameState } from '@/types'
|
||||
import { challenges } from '@/data/challenges'
|
||||
import { rooms } from '@/data/rooms'
|
||||
import { isChallengeResolved } from '@/engine/navigation'
|
||||
import { resolveChallenge } from '@/engine/stateMachine'
|
||||
|
||||
function renderChallenge(state: GameState): void {
|
||||
const mount = document.getElementById('challenge-mount')
|
||||
if (!mount) return
|
||||
|
||||
mount.innerHTML = ''
|
||||
|
||||
const roomId = state.save.currentRun.currentRoom
|
||||
const room = rooms[roomId]
|
||||
if (!room?.challengeId) return
|
||||
|
||||
const challenge = challenges[room.challengeId]
|
||||
if (!challenge) return
|
||||
|
||||
if (isChallengeResolved(challenge.id, state.save)) {
|
||||
mount.appendChild(renderResultBanner(challenge, state))
|
||||
return
|
||||
}
|
||||
|
||||
mount.appendChild(renderChallengePanel(challenge))
|
||||
}
|
||||
|
||||
function renderChallengePanel(challenge: ChallengeDefinition): HTMLElement {
|
||||
const panel = document.createElement('div')
|
||||
panel.id = 'challenge-panel'
|
||||
panel.className = 'active'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.id = 'challenge-header'
|
||||
header.innerHTML = `
|
||||
<span class="icon">⚡</span>
|
||||
<span id="challenge-title">${challenge.title}</span>
|
||||
`
|
||||
|
||||
const desc = document.createElement('div')
|
||||
desc.id = 'challenge-desc'
|
||||
desc.textContent = challenge.description
|
||||
|
||||
const choicesEl = document.createElement('div')
|
||||
choicesEl.id = 'challenge-choices'
|
||||
|
||||
for (const choice of challenge.choices) {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'challenge-choice-btn'
|
||||
btn.innerHTML = `
|
||||
<div class="choice-icon-wrap">
|
||||
<span class="choice-key">${choice.key}</span>
|
||||
<div class="choice-icon"></div>
|
||||
</div>
|
||||
<div class="choice-text">
|
||||
<span class="choice-label">${choice.label}</span>
|
||||
<span class="choice-hint">${choice.hint}</span>
|
||||
</div>
|
||||
`
|
||||
btn.addEventListener('click', () => resolveChallenge(challenge, choice.key))
|
||||
choicesEl.appendChild(btn)
|
||||
}
|
||||
|
||||
panel.appendChild(header)
|
||||
panel.appendChild(desc)
|
||||
panel.appendChild(choicesEl)
|
||||
return panel
|
||||
}
|
||||
|
||||
function renderResultBanner(
|
||||
challenge: ChallengeDefinition,
|
||||
state: GameState
|
||||
): HTMLElement {
|
||||
const result = state.save.currentRun.resolvedChallenges[challenge.id]
|
||||
const choice = challenge.choices.find((c) => c.key === result?.choiceKey)
|
||||
|
||||
const banner = document.createElement('div')
|
||||
banner.id = 'result-banner'
|
||||
banner.className = `active ${result?.rating ?? ''}`
|
||||
|
||||
const ratingLabel =
|
||||
result?.rating === 'good' ? 'GOOD' : result?.rating === 'ok' ? 'OK' : 'BAD'
|
||||
|
||||
let html = `
|
||||
<strong class="rating-${result?.rating ?? ''}">${ratingLabel}</strong>
|
||||
— ${choice?.feedback ?? ''}
|
||||
`
|
||||
|
||||
if (result?.choiceKey !== challenge.recommended) {
|
||||
const recommended = challenge.choices.find(
|
||||
(c) => c.key === challenge.recommended
|
||||
)
|
||||
if (recommended) {
|
||||
html += `
|
||||
<div class="result-recommended">
|
||||
<strong>Recommended:</strong> ${recommended.label} — ${recommended.hint}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
if (challenge.docLink) {
|
||||
html += `
|
||||
<div style="margin-top:8px">
|
||||
<a class="result-doc-link" href="${challenge.docLink.url}" target="_blank" rel="noopener">
|
||||
${challenge.docLink.label} ↗
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
banner.innerHTML = html
|
||||
return banner
|
||||
}
|
||||
|
||||
export { renderChallenge }
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { buildNarrativeSummary } from '@/data/narrative'
|
||||
import { resetForPrestige } from '@/engine/stateMachine'
|
||||
import { persistSave } from '@/state/gameState'
|
||||
import { canPrestige, prestige } from '@/state/prestige'
|
||||
|
||||
function renderPrestigeSection(state: GameState, summary: string): HTMLElement {
|
||||
const section = document.createElement('div')
|
||||
section.className = 'prestige-section'
|
||||
|
||||
if (canPrestige(state.save)) {
|
||||
const teaser = document.createElement('p')
|
||||
teaser.className = 'prestige-teaser'
|
||||
teaser.textContent =
|
||||
'The architecture breathes. Deeper layers await — more entangled, more instructive. Are you ready to descend?'
|
||||
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'prestige-btn'
|
||||
btn.textContent = 'Descend Deeper'
|
||||
btn.addEventListener('click', () => {
|
||||
const newSave = prestige(state.save, summary)
|
||||
persistSave(newSave)
|
||||
resetForPrestige(newSave)
|
||||
})
|
||||
|
||||
section.appendChild(teaser)
|
||||
section.appendChild(btn)
|
||||
} else {
|
||||
const maxLayer = document.createElement('p')
|
||||
maxLayer.className = 'max-layer-text'
|
||||
maxLayer.textContent = 'You have reached the deepest layer.'
|
||||
section.appendChild(maxLayer)
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
function renderEnding(state: GameState): void {
|
||||
const main = document.getElementById('main')
|
||||
if (!main) return
|
||||
|
||||
const run = state.save.currentRun
|
||||
const summary = buildNarrativeSummary(run.resolvedChallenges)
|
||||
const resolvedCount = Object.keys(run.resolvedChallenges).length
|
||||
const conceptCount = run.conceptTags.length
|
||||
|
||||
main.innerHTML = ''
|
||||
|
||||
const title = document.createElement('h2')
|
||||
title.className = 'ending-title'
|
||||
title.textContent = 'State of the Codebase'
|
||||
|
||||
const narrative = document.createElement('p')
|
||||
narrative.className = 'ending-narrative'
|
||||
narrative.textContent = summary
|
||||
|
||||
const stats = document.createElement('div')
|
||||
stats.className = 'ending-stats'
|
||||
stats.innerHTML = `
|
||||
<div class="stat"><span class="stat-label">Insight Earned</span><span class="stat-value">${run.insightEarned}</span></div>
|
||||
<div class="stat"><span class="stat-label">Challenges Resolved</span><span class="stat-value">${resolvedCount}</span></div>
|
||||
<div class="stat"><span class="stat-label">Concepts Learned</span><span class="stat-value">${conceptCount}</span></div>
|
||||
<div class="stat"><span class="stat-label">Current Layer</span><span class="stat-value">${run.layer}</span></div>
|
||||
`
|
||||
|
||||
main.appendChild(title)
|
||||
main.appendChild(narrative)
|
||||
main.appendChild(stats)
|
||||
main.appendChild(renderPrestigeSection(state, summary))
|
||||
}
|
||||
|
||||
export { renderEnding }
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { challenges } from '@/data/challenges'
|
||||
import { countResolvedChallenges } from '@/engine/navigation'
|
||||
|
||||
function createHud(): HTMLElement {
|
||||
const hud = document.createElement('header')
|
||||
hud.id = 'hud'
|
||||
hud.innerHTML = `
|
||||
<h1 id="game-title">Codebase Caverns</h1>
|
||||
<div id="hud-right">
|
||||
<div id="hud-insight">
|
||||
<span class="hud-label">Insight</span>
|
||||
<span id="insight-value">0</span>
|
||||
</div>
|
||||
<div id="hud-progress">
|
||||
<span class="hud-label">Challenges</span>
|
||||
<span id="progress-value">0/0</span>
|
||||
</div>
|
||||
<button id="toggle-map" type="button">Map [M]</button>
|
||||
<button id="restart-btn" type="button">Restart</button>
|
||||
</div>
|
||||
`
|
||||
return hud
|
||||
}
|
||||
|
||||
function renderHud(state: GameState): void {
|
||||
const insightEl = document.getElementById('insight-value')
|
||||
const progressEl = document.getElementById('progress-value')
|
||||
|
||||
if (insightEl) {
|
||||
const total =
|
||||
state.save.persistent.totalInsight + state.save.currentRun.insightEarned
|
||||
insightEl.textContent = String(total)
|
||||
}
|
||||
|
||||
if (progressEl) {
|
||||
const resolved = countResolvedChallenges(state.save)
|
||||
const total = Object.keys(challenges).length
|
||||
progressEl.textContent = `${resolved}/${total}`
|
||||
}
|
||||
}
|
||||
|
||||
export { createHud, renderHud }
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { edges } from '@/data/graph'
|
||||
import { rooms } from '@/data/rooms'
|
||||
import { isChallengeResolved, isRoomDiscovered } from '@/engine/navigation'
|
||||
import { enterRoom } from '@/engine/stateMachine'
|
||||
import { canEnterRoom } from '@/state/tags'
|
||||
|
||||
interface NodePosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const NODE_POSITIONS: Record<string, NodePosition> = {
|
||||
entry: { x: 300, y: 40 },
|
||||
components: { x: 120, y: 140 },
|
||||
stores: { x: 300, y: 140 },
|
||||
services: { x: 480, y: 140 },
|
||||
litegraph: { x: 60, y: 260 },
|
||||
sidepanel: { x: 180, y: 260 },
|
||||
ecs: { x: 300, y: 260 },
|
||||
renderer: { x: 420, y: 260 },
|
||||
composables: { x: 540, y: 260 },
|
||||
subgraph: { x: 300, y: 370 }
|
||||
}
|
||||
|
||||
const SVG_WIDTH = 600
|
||||
const SVG_HEIGHT = 440
|
||||
const NODE_RADIUS = 28
|
||||
|
||||
function getNodeState(
|
||||
roomId: string,
|
||||
state: GameState
|
||||
): 'locked' | 'visited' | 'current' {
|
||||
if (roomId === state.save.currentRun.currentRoom) return 'current'
|
||||
if (isRoomDiscovered(roomId, state.save)) return 'visited'
|
||||
return 'locked'
|
||||
}
|
||||
|
||||
function createSvgElement<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K
|
||||
): SVGElementTagNameMap[K] {
|
||||
return document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||
}
|
||||
|
||||
function buildEdges(): SVGGElement {
|
||||
const g = createSvgElement('g')
|
||||
const drawn = new Set<string>()
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = [edge.from, edge.to].sort().join('--')
|
||||
if (drawn.has(key)) continue
|
||||
drawn.add(key)
|
||||
|
||||
const from = NODE_POSITIONS[edge.from]
|
||||
const to = NODE_POSITIONS[edge.to]
|
||||
if (!from || !to) continue
|
||||
|
||||
const line = createSvgElement('line')
|
||||
line.setAttribute('class', 'map-edge')
|
||||
line.setAttribute('x1', String(from.x))
|
||||
line.setAttribute('y1', String(from.y))
|
||||
line.setAttribute('x2', String(to.x))
|
||||
line.setAttribute('y2', String(to.y))
|
||||
g.appendChild(line)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
function buildNode(
|
||||
roomId: string,
|
||||
state: GameState,
|
||||
onSelect: (id: string) => void
|
||||
): SVGGElement {
|
||||
const room = rooms[roomId]
|
||||
const pos = NODE_POSITIONS[roomId]
|
||||
if (!room || !pos) return createSvgElement('g')
|
||||
|
||||
const nodeState = getNodeState(roomId, state)
|
||||
const accessible = canEnterRoom(room, state.save)
|
||||
|
||||
const g = createSvgElement('g')
|
||||
g.setAttribute('class', `map-node ${nodeState}`)
|
||||
g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`)
|
||||
|
||||
if (accessible && nodeState !== 'locked') {
|
||||
g.style.cursor = 'pointer'
|
||||
g.addEventListener('click', () => onSelect(roomId))
|
||||
}
|
||||
|
||||
const circle = createSvgElement('circle')
|
||||
circle.setAttribute('r', String(NODE_RADIUS))
|
||||
circle.setAttribute('cx', '0')
|
||||
circle.setAttribute('cy', '0')
|
||||
g.appendChild(circle)
|
||||
|
||||
const label = createSvgElement('text')
|
||||
label.setAttribute('class', 'map-label')
|
||||
label.setAttribute('text-anchor', 'middle')
|
||||
label.setAttribute('dominant-baseline', 'middle')
|
||||
label.setAttribute('y', '0')
|
||||
label.textContent = room.id
|
||||
g.appendChild(label)
|
||||
|
||||
const layerLabel = createSvgElement('text')
|
||||
layerLabel.setAttribute('class', 'map-title')
|
||||
layerLabel.setAttribute('text-anchor', 'middle')
|
||||
layerLabel.setAttribute('y', String(NODE_RADIUS + 12))
|
||||
layerLabel.textContent = room.layer
|
||||
g.appendChild(layerLabel)
|
||||
|
||||
if (nodeState === 'locked') {
|
||||
const lock = createSvgElement('text')
|
||||
lock.setAttribute('class', 'map-lock')
|
||||
lock.setAttribute('text-anchor', 'middle')
|
||||
lock.setAttribute('dominant-baseline', 'middle')
|
||||
lock.setAttribute('y', String(-NODE_RADIUS - 8))
|
||||
lock.textContent = '🔒'
|
||||
g.appendChild(lock)
|
||||
} else if (room.challengeId) {
|
||||
const resolved = isChallengeResolved(room.challengeId, state.save)
|
||||
const badge = createSvgElement('text')
|
||||
badge.setAttribute('class', 'map-badge')
|
||||
badge.setAttribute('text-anchor', 'middle')
|
||||
badge.setAttribute('dominant-baseline', 'middle')
|
||||
badge.setAttribute('y', String(-NODE_RADIUS - 8))
|
||||
badge.textContent = resolved ? '✓' : '?'
|
||||
g.appendChild(badge)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
function buildSvg(
|
||||
state: GameState,
|
||||
onSelect: (id: string) => void
|
||||
): SVGSVGElement {
|
||||
const svg = createSvgElement('svg')
|
||||
svg.setAttribute('viewBox', `0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`)
|
||||
svg.setAttribute('width', '100%')
|
||||
svg.setAttribute('style', 'max-height: 440px;')
|
||||
|
||||
svg.appendChild(buildEdges())
|
||||
|
||||
for (const roomId of Object.keys(rooms)) {
|
||||
svg.appendChild(buildNode(roomId, state, onSelect))
|
||||
}
|
||||
|
||||
return svg
|
||||
}
|
||||
|
||||
function getDialog(): HTMLDialogElement | null {
|
||||
return document.getElementById('map-dialog') as HTMLDialogElement | null
|
||||
}
|
||||
|
||||
function createMapOverlay(): HTMLDialogElement {
|
||||
const dialog = document.createElement('dialog')
|
||||
dialog.id = 'map-dialog'
|
||||
dialog.innerHTML = '<h3>Map</h3><div id="map-svg-container"></div>'
|
||||
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) dialog.close()
|
||||
})
|
||||
|
||||
document.body.appendChild(dialog)
|
||||
return dialog
|
||||
}
|
||||
|
||||
function renderMap(state: GameState): void {
|
||||
const container = document.getElementById('map-svg-container')
|
||||
if (!container) return
|
||||
|
||||
container.innerHTML = ''
|
||||
|
||||
const svg = buildSvg(state, (roomId) => {
|
||||
enterRoom(roomId)
|
||||
getDialog()?.close()
|
||||
})
|
||||
|
||||
container.appendChild(svg)
|
||||
}
|
||||
|
||||
function toggleMap(): void {
|
||||
const dialog = getDialog()
|
||||
if (!dialog) return
|
||||
|
||||
if (dialog.open) {
|
||||
dialog.close()
|
||||
} else {
|
||||
dialog.showModal()
|
||||
}
|
||||
}
|
||||
|
||||
export { createMapOverlay, renderMap, toggleMap }
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { challenges } from '@/data/challenges'
|
||||
import { countResolvedChallenges } from '@/engine/navigation'
|
||||
import { showEnding } from '@/engine/stateMachine'
|
||||
import { clearSave } from '@/state/gameState'
|
||||
import { createHud, renderHud } from '@/ui/hud'
|
||||
import { renderChallenge } from '@/ui/challengeView'
|
||||
import { renderEnding } from '@/ui/endingView'
|
||||
import { createMapOverlay, renderMap, toggleMap } from '@/ui/nodeMap'
|
||||
import { createRoomView, renderRoom } from '@/ui/roomView'
|
||||
import { createSidebar, renderSidebar } from '@/ui/sidebar'
|
||||
|
||||
function mountApp(): void {
|
||||
const app = document.getElementById('app')
|
||||
if (!app) throw new Error('Missing #app element')
|
||||
|
||||
app.appendChild(createHud())
|
||||
app.appendChild(createRoomView())
|
||||
app.appendChild(createSidebar())
|
||||
createMapOverlay()
|
||||
|
||||
const toggleBtn = document.getElementById('toggle-map')
|
||||
toggleBtn?.addEventListener('click', toggleMap)
|
||||
|
||||
const restartBtn = document.getElementById('restart-btn')
|
||||
restartBtn?.addEventListener('click', () => {
|
||||
clearSave()
|
||||
location.reload()
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
const tag = (e.target as HTMLElement).tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
|
||||
if (e.key === 'M' || e.key === 'm') {
|
||||
toggleMap()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const dialog = document.getElementById(
|
||||
'map-dialog'
|
||||
) as HTMLDialogElement | null
|
||||
if (dialog?.open) dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
const numMatch = e.key.match(/^[1-9]$/)
|
||||
if (numMatch) {
|
||||
const index = parseInt(e.key, 10) - 1
|
||||
const choices = document.querySelectorAll<HTMLButtonElement>('.choice-btn')
|
||||
choices[index]?.click()
|
||||
return
|
||||
}
|
||||
|
||||
const letterMatch = e.key.match(/^[A-Ca-c]$/)
|
||||
if (letterMatch) {
|
||||
const key = e.key.toUpperCase()
|
||||
const choices = document.querySelectorAll<HTMLButtonElement>(
|
||||
'.challenge-choice-btn'
|
||||
)
|
||||
const match = Array.from(choices).find(
|
||||
(btn) => btn.querySelector('.choice-key')?.textContent === key
|
||||
)
|
||||
match?.click()
|
||||
}
|
||||
}
|
||||
|
||||
function render(state: GameState): void {
|
||||
renderHud(state)
|
||||
renderSidebar(state)
|
||||
renderMap(state)
|
||||
|
||||
if (state.phase === 'ending') {
|
||||
renderEnding(state)
|
||||
return
|
||||
}
|
||||
|
||||
renderRoom(state)
|
||||
renderChallenge(state)
|
||||
|
||||
const totalChallenges = Object.keys(challenges).length
|
||||
const resolved = countResolvedChallenges(state.save)
|
||||
if (resolved >= totalChallenges) {
|
||||
showEnding()
|
||||
}
|
||||
}
|
||||
|
||||
export { mountApp, render }
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { rooms } from '@/data/rooms'
|
||||
import { isChallengeResolved } from '@/engine/navigation'
|
||||
import { enterRoom } from '@/engine/stateMachine'
|
||||
import { canEnterRoom } from '@/state/tags'
|
||||
|
||||
function createRoomView(): HTMLElement {
|
||||
const main = document.createElement('main')
|
||||
main.id = 'main'
|
||||
main.innerHTML = `
|
||||
<div id="room-header">
|
||||
<h2 id="room-title"></h2>
|
||||
<div id="room-layer"></div>
|
||||
</div>
|
||||
<div id="room-image" class="room-image placeholder"></div>
|
||||
<p id="room-description"></p>
|
||||
<div id="challenge-mount"></div>
|
||||
<div id="room-choices"></div>
|
||||
`
|
||||
return main
|
||||
}
|
||||
|
||||
function renderRoom(state: GameState): void {
|
||||
const roomId = state.save.currentRun.currentRoom
|
||||
const room = rooms[roomId]
|
||||
if (!room) return
|
||||
|
||||
const titleEl = document.getElementById('room-title')
|
||||
if (titleEl) titleEl.textContent = room.title
|
||||
|
||||
const layerEl = document.getElementById('room-layer')
|
||||
if (layerEl) layerEl.textContent = room.layer
|
||||
|
||||
const imageEl = document.getElementById('room-image')
|
||||
if (imageEl) {
|
||||
if (room.imageUrl) {
|
||||
imageEl.innerHTML = `<img src="${room.imageUrl}" alt="${room.title}" />`
|
||||
imageEl.className = 'room-image'
|
||||
} else {
|
||||
imageEl.innerHTML = `<span>${room.layer}</span>`
|
||||
imageEl.className = 'room-image placeholder'
|
||||
}
|
||||
}
|
||||
|
||||
const descEl = document.getElementById('room-description')
|
||||
if (descEl) {
|
||||
const challengeResolved =
|
||||
room.challengeId !== undefined &&
|
||||
isChallengeResolved(room.challengeId, state.save)
|
||||
const showSolution = challengeResolved && room.solutionDescription !== ''
|
||||
descEl.textContent = showSolution
|
||||
? room.solutionDescription
|
||||
: room.discoveryDescription
|
||||
}
|
||||
|
||||
const choicesEl = document.getElementById('room-choices')
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = ''
|
||||
room.connections.forEach((conn, index) => {
|
||||
const targetRoom = rooms[conn.targetRoomId]
|
||||
if (!targetRoom) return
|
||||
|
||||
const accessible = canEnterRoom(targetRoom, state.save)
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'choice-btn' + (accessible ? '' : ' locked')
|
||||
|
||||
btn.innerHTML = `
|
||||
<span class="choice-key">${index + 1}</span>
|
||||
<span class="choice-label">${conn.label}</span>
|
||||
<span class="choice-hint">${accessible ? conn.hint : '🔒 ' + conn.hint}</span>
|
||||
`
|
||||
|
||||
if (accessible) {
|
||||
btn.addEventListener('click', () => enterRoom(conn.targetRoomId))
|
||||
}
|
||||
|
||||
choicesEl.appendChild(btn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { createRoomView, renderRoom }
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { GameState } from '@/types'
|
||||
|
||||
function createSidebar(): HTMLElement {
|
||||
const sidebar = document.createElement('aside')
|
||||
sidebar.id = 'sidebar'
|
||||
sidebar.innerHTML = `
|
||||
<div id="concept-tags">
|
||||
<h3 class="sidebar-header">Concept Tags</h3>
|
||||
<div id="tags-list"></div>
|
||||
</div>
|
||||
<div id="artifacts-panel">
|
||||
<h3 class="sidebar-header">Artifacts</h3>
|
||||
<div id="artifacts-list"></div>
|
||||
</div>
|
||||
<div id="run-log">
|
||||
<h3 class="sidebar-header">Log</h3>
|
||||
<div id="log-entries"></div>
|
||||
</div>
|
||||
`
|
||||
return sidebar
|
||||
}
|
||||
|
||||
function renderSidebar(state: GameState): void {
|
||||
const tagsList = document.getElementById('tags-list')
|
||||
if (tagsList) {
|
||||
tagsList.innerHTML = state.save.currentRun.conceptTags
|
||||
.map((tag) => `<span class="tag-pill">${tag}</span>`)
|
||||
.join('')
|
||||
|
||||
if (state.save.currentRun.conceptTags.length === 0) {
|
||||
tagsList.innerHTML =
|
||||
'<span class="empty-hint">None yet — explore and solve challenges</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { createSidebar, renderSidebar }
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
root: projectRoot,
|
||||
base: './',
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
assetsInlineLimit: 1_000_000,
|
||||
cssCodeSplit: false,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(projectRoot, 'src')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028ZM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38Zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 819 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 988 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 536 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm5.8 11.33c.02.16.03.33.03.5 0 2.55-2.97 4.63-6.63 4.63-3.65 0-6.62-2.07-6.62-4.63 0-.17.01-.34.03-.5a1.58 1.58 0 0 1-.63-1.27c0-.88.72-1.59 1.6-1.59.44 0 .83.18 1.12.46 1.1-.79 2.62-1.3 4.31-1.37l.73-3.44a.32.32 0 0 1 .39-.24l2.43.52a1.13 1.13 0 0 1 2.15.36 1.13 1.13 0 0 1-1.13 1.12 1.13 1.13 0 0 1-1.08-.82l-2.16-.46-.65 3.07c1.65.09 3.14.59 4.22 1.36.29-.28.69-.46 1.13-.46.88 0 1.6.71 1.6 1.59 0 .52-.25.97-.63 1.27ZM9.5 13.5c0 .63.51 1.13 1.13 1.13s1.12-.5 1.12-1.13-.5-1.12-1.12-1.12-1.13.5-1.13 1.12Zm5.75 2.55c-.69.69-2 .73-3.25.73s-2.56-.04-3.25-.73a.32.32 0 1 1 .45-.45c.44.44 1.37.6 2.8.6 1.43 0 2.37-.16 2.8-.6a.32.32 0 1 1 .45.45Zm-.37-1.42c.62 0 1.13-.5 1.13-1.13 0-.62-.51-1.12-1.13-1.12-.63 0-1.13.5-1.13 1.12 0 .63.5 1.13 1.13 1.13Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 915 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
|
Before Width: | Height: | Size: 254 B |
@@ -1,143 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const columns = [
|
||||
{
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'Comfy Desktop', href: '/download' },
|
||||
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
|
||||
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
|
||||
{ label: 'Pricing', href: '/pricing' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{ label: 'Documentation', href: 'https://docs.comfy.org' },
|
||||
{ label: 'Blog', href: 'https://blog.comfy.org' },
|
||||
{ label: 'Gallery', href: '/gallery' },
|
||||
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Legal',
|
||||
links: [
|
||||
{ label: 'Terms of Service', href: '/terms-of-service' },
|
||||
{ label: 'Privacy Policy', href: '/privacy-policy' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const socials = [
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
icon: '/icons/social/github.svg'
|
||||
},
|
||||
{
|
||||
label: 'Discord',
|
||||
href: 'https://discord.gg/comfyorg',
|
||||
icon: '/icons/social/discord.svg'
|
||||
},
|
||||
{
|
||||
label: 'X',
|
||||
href: 'https://x.com/comaboratory',
|
||||
icon: '/icons/social/x.svg'
|
||||
},
|
||||
{
|
||||
label: 'Reddit',
|
||||
href: 'https://reddit.com/r/comfyui',
|
||||
icon: '/icons/social/reddit.svg'
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
href: 'https://linkedin.com/company/comfyorg',
|
||||
icon: '/icons/social/linkedin.svg'
|
||||
},
|
||||
{
|
||||
label: 'Instagram',
|
||||
href: 'https://instagram.com/comfyorg',
|
||||
icon: '/icons/social/instagram.svg'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-white/10 bg-black">
|
||||
<div
|
||||
class="mx-auto grid max-w-7xl gap-8 px-6 py-16 sm:grid-cols-2 lg:grid-cols-5"
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="lg:col-span-1">
|
||||
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
|
||||
Comfy
|
||||
</a>
|
||||
<p class="mt-4 text-sm text-smoke-700">
|
||||
Professional control of visual AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Link columns -->
|
||||
<nav
|
||||
v-for="column in columns"
|
||||
:key="column.title"
|
||||
:aria-label="column.title"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-white">{{ column.title }}</h3>
|
||||
<a
|
||||
v-for="link in column.links"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
class="text-sm text-smoke-700 transition-colors hover:text-white"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="border-t border-white/10">
|
||||
<div
|
||||
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
© {{ 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>
|
||||
@@ -1,149 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const currentPath = ref('')
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'ENTERPRISE', href: '/enterprise' },
|
||||
{ label: 'GALLERY', href: '/gallery' },
|
||||
{ label: 'ABOUT', href: '/about' },
|
||||
{ label: 'CAREERS', href: '/careers' }
|
||||
]
|
||||
|
||||
const ctaLinks = [
|
||||
{
|
||||
label: 'COMFY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
label: 'COMFY HUB',
|
||||
href: 'https://hub.comfy.org',
|
||||
primary: false
|
||||
}
|
||||
]
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && mobileMenuOpen.value) {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onAfterSwap() {
|
||||
mobileMenuOpen.value = false
|
||||
currentPath.value = window.location.pathname
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
document.addEventListener('astro:after-swap', onAfterSwap)
|
||||
currentPath.value = window.location.pathname
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
document.removeEventListener('astro:after-swap', onAfterSwap)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
|
||||
Comfy
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div class="hidden items-center gap-8 md:flex">
|
||||
<a
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:aria-current="currentPath === link.href ? 'page' : undefined"
|
||||
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
v-for="cta in ctaLinks"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
{{ cta.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
class="flex flex-col gap-1.5 md:hidden"
|
||||
aria-label="Toggle menu"
|
||||
aria-controls="site-mobile-menu"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<span
|
||||
class="block h-0.5 w-6 bg-white transition-transform"
|
||||
:class="mobileMenuOpen && 'translate-y-2 rotate-45'"
|
||||
/>
|
||||
<span
|
||||
class="block h-0.5 w-6 bg-white transition-opacity"
|
||||
:class="mobileMenuOpen && 'opacity-0'"
|
||||
/>
|
||||
<span
|
||||
class="block h-0.5 w-6 bg-white transition-transform"
|
||||
:class="mobileMenuOpen && '-translate-y-2 -rotate-45'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div
|
||||
v-show="mobileMenuOpen"
|
||||
id="site-mobile-menu"
|
||||
class="border-t border-white/10 bg-black px-6 pb-6 md:hidden"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<a
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:aria-current="currentPath === link.href ? 'page' : undefined"
|
||||
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-3 pt-2">
|
||||
<a
|
||||
v-for="cta in ctaLinks"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
|
||||
>
|
||||
{{ cta.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
import Analytics from '@vercel/analytics/astro'
|
||||
import '../styles/global.css'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
ogImage?: string
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
ogImage = '/og-default.png',
|
||||
} = Astro.props
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const locale = Astro.currentLocale ?? 'en'
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImageURL.href} />
|
||||
<meta property="og:url" content={canonicalURL.href} />
|
||||
<meta property="og:locale" content={locale} />
|
||||
<meta property="og:site_name" content="Comfy" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmEnabled && (
|
||||
<script is:inline define:vars={{ gtmId }}>
|
||||
;(function (w, d, s, l, i) {
|
||||
w[l] = w[l] || []
|
||||
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' })
|
||||
var f = d.getElementsByTagName(s)[0],
|
||||
j = d.createElement(s),
|
||||
dl = l != 'dataLayer' ? '&l=' + l : ''
|
||||
j.async = true
|
||||
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
|
||||
f.parentNode.insertBefore(j, f)
|
||||
})(window, document, 'script', 'dataLayer', gtmId)
|
||||
</script>
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body class="bg-black text-white font-inter antialiased">
|
||||
{gtmEnabled && (
|
||||
<noscript>
|
||||
<iframe
|
||||
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
|
||||
height="0"
|
||||
width="0"
|
||||
style="display:none;visibility:hidden"
|
||||
></iframe>
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
<slot />
|
||||
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,5 +5,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.ts"]
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
}
|
||||
|
||||
@@ -30,24 +30,6 @@ browser_tests/
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
## Polling Assertions
|
||||
|
||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||
|
||||
```typescript
|
||||
// ✅ Correct — single async call + single assertion
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
|
||||
.toBe(0)
|
||||
|
||||
// ❌ Avoid — nested expect inside toPass
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 250 })
|
||||
```
|
||||
|
||||
Reserve `toPass()` for blocks with multiple assertions or complex async logic that can't be expressed as a single polled value.
|
||||
|
||||
## Gotchas
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|
||||
@@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example
|
||||
**Always use UI mode for development:**
|
||||
|
||||
```bash
|
||||
pnpm test:browser:local --ui
|
||||
pnpm exec playwright test --ui
|
||||
```
|
||||
|
||||
UI mode features:
|
||||
@@ -91,8 +91,29 @@ UI mode features:
|
||||
For CI or headless testing:
|
||||
|
||||
```bash
|
||||
pnpm test:browser:local # Run all tests
|
||||
pnpm test:browser:local widget.spec.ts # Run specific test file
|
||||
pnpm exec playwright test # Run all tests
|
||||
pnpm exec playwright test widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
### Local Development Config
|
||||
|
||||
For debugging, you can try adjusting these settings in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
// VERY HELPFUL: Skip screenshot tests locally
|
||||
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
|
||||
|
||||
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
|
||||
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
|
||||
timeout: 30000, // Longer timeout for breakpoints
|
||||
|
||||
use: {
|
||||
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
|
||||
video: 'on' // Always record video (CI uses 'retain-on-failure')
|
||||
},
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
@@ -364,7 +385,7 @@ export default defineConfig({
|
||||
Option 2 - Generate local baselines for comparison:
|
||||
|
||||
```bash
|
||||
pnpm test:browser:local --update-snapshots
|
||||
pnpm exec playwright test --update-snapshots
|
||||
```
|
||||
|
||||
### Creating New Screenshot Baselines
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
{
|
||||
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
|
||||
"revision": 0,
|
||||
"last_node_id": 61,
|
||||
"last_link_id": 70,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 35,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [-424.0076397768001, 199.99406275798367],
|
||||
"size": [510, 774],
|
||||
"flags": {
|
||||
"collapsed": false
|
||||
},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Model link",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ ├── 📂 text_encoders/\n│ │ └── qwen_3_4b.safetensors\n│ ├── 📂 loras/\n│ │ └── pixel_art_style_z_image_turbo.safetensors\n│ ├── 📂 diffusion_models/\n│ │ └── z_image_turbo_bf16.safetensors\n│ └── 📂 vae/\n│ └── ae.safetensors\n```\n"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#000"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [569.9875743118757, 199.99406275798367],
|
||||
"size": [780, 660],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 62
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.64",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": ["z-image-turbo"]
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"type": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
|
||||
"pos": [128.01215102992103, 199.99406275798367],
|
||||
"size": [400, 470],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "prompt",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [62]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["27", "text"],
|
||||
["13", "width"],
|
||||
["13", "height"],
|
||||
["28", "unet_name"],
|
||||
["30", "clip_name"],
|
||||
["29", "vae_name"],
|
||||
["3", "steps"],
|
||||
["3", "control_after_generate"]
|
||||
],
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.73",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [[62, 57, 0, 9, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 4,
|
||||
"lastNodeId": 61,
|
||||
"lastLinkId": 70,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Text to Image (Z-Image-Turbo)",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-80, 425, 120, 180]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1490, 415, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "fb178669-e742-4a53-8a69-7df59834dfd8",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [34],
|
||||
"label": "prompt",
|
||||
"pos": [20, 445]
|
||||
},
|
||||
{
|
||||
"id": "dd780b3c-23e9-46ff-8469-156008f42e5a",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"linkIds": [35],
|
||||
"pos": [20, 465]
|
||||
},
|
||||
{
|
||||
"id": "7b08d546-6bb0-4ef9-82e9-ffae5e1ee6bc",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"linkIds": [36],
|
||||
"pos": [20, 485]
|
||||
},
|
||||
{
|
||||
"id": "8ed4eb73-a2bf-4766-8bf4-c5890b560596",
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [38],
|
||||
"pos": [20, 505]
|
||||
},
|
||||
{
|
||||
"id": "f362d639-d412-4b5d-8490-1e9995dc5f82",
|
||||
"name": "clip_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [39],
|
||||
"pos": [20, 525]
|
||||
},
|
||||
{
|
||||
"id": "ee25ac16-de63-4b74-bbbb-5b29fdc1efcf",
|
||||
"name": "vae_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [40],
|
||||
"pos": [20, 545]
|
||||
},
|
||||
{
|
||||
"id": "51cbcd61-9218-4bcb-89ac-ecdfb1ef8892",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"linkIds": [70],
|
||||
"pos": [20, 565]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "1fa72a21-ce00-4952-814e-1f2ffbe87d1d",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [16],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1510, 435]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 30,
|
||||
"type": "CLIPLoader",
|
||||
"pos": [110, 330],
|
||||
"size": [270, 106],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip_name",
|
||||
"name": "clip_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "clip_name"
|
||||
},
|
||||
"link": 39
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [28]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPLoader",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.73",
|
||||
"models": [
|
||||
{
|
||||
"name": "qwen_3_4b.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
|
||||
"directory": "text_encoders"
|
||||
}
|
||||
],
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": ["qwen_3_4b.safetensors", "lumina2", "default"]
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "VAELoader",
|
||||
"pos": [110, 480],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "vae_name",
|
||||
"name": "vae_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "vae_name"
|
||||
},
|
||||
"link": 40
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [27]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAELoader",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.73",
|
||||
"models": [
|
||||
{
|
||||
"name": "ae.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
|
||||
"directory": "vae"
|
||||
}
|
||||
],
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": ["ae.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"type": "ConditioningZeroOut",
|
||||
"pos": [640, 620],
|
||||
"size": [204.134765625, 26],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "conditioning",
|
||||
"name": "conditioning",
|
||||
"type": "CONDITIONING",
|
||||
"link": 32
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [33]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ConditioningZeroOut",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.73",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1220, 160],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 27
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [16]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.64",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"type": "UNETLoader",
|
||||
"pos": [110, 200],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "unet_name",
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "unet_name"
|
||||
},
|
||||
"link": 38
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [26]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UNETLoader",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.73",
|
||||
"models": [
|
||||
{
|
||||
"name": "z_image_turbo_bf16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
|
||||
"directory": "diffusion_models"
|
||||
}
|
||||
],
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": ["z_image_turbo_bf16.safetensors", "default"]
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [430, 200],
|
||||
"size": [410, 370],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 28
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": 34
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [30, 32]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.73",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": [
|
||||
"Latina female with thick wavy hair, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up. "
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "EmptySD3LatentImage",
|
||||
"pos": [110, 630],
|
||||
"size": [260, 110],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "width",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": 35
|
||||
},
|
||||
{
|
||||
"localized_name": "height",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": 36
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [17]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptySD3LatentImage",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.64",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "ModelSamplingAuraFlow",
|
||||
"pos": [880, 160],
|
||||
"size": [310, 60],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 26
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ModelSamplingAuraFlow",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.64",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": [3]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [880, 270],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 30
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 33
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 17
|
||||
},
|
||||
{
|
||||
"localized_name": "steps",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": 70
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.64",
|
||||
"enableTabs": false,
|
||||
"tabWidth": 65,
|
||||
"tabXOffset": 10,
|
||||
"hasSecondTab": false,
|
||||
"secondTabText": "Send Back",
|
||||
"secondTabOffset": 80,
|
||||
"secondTabWidth": 65
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
8,
|
||||
1,
|
||||
"res_multistep",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Step2 - Image size",
|
||||
"bounding": [100, 560, 290, 200],
|
||||
"color": "#3f789e",
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Step3 - Prompt",
|
||||
"bounding": [410, 130, 450, 540],
|
||||
"color": "#3f789e",
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Step1 - Load models",
|
||||
"bounding": [100, 130, 290, 413.6],
|
||||
"color": "#3f789e",
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 32,
|
||||
"origin_id": 27,
|
||||
"origin_slot": 0,
|
||||
"target_id": 33,
|
||||
"target_slot": 0,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"origin_id": 28,
|
||||
"origin_slot": 0,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 8,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"origin_id": 29,
|
||||
"origin_slot": 0,
|
||||
"target_id": 8,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"origin_id": 27,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"origin_id": 33,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": 13,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"origin_id": 30,
|
||||
"origin_slot": 0,
|
||||
"target_id": 27,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 27,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 13,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 13,
|
||||
"target_slot": 1,
|
||||
"type": "INT"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 28,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 30,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 29,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
},
|
||||
{
|
||||
"id": 70,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 6,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "LG"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6488294314381271,
|
||||
"offset": [733, 392.7886597938144]
|
||||
},
|
||||
"frontendVersion": "1.43.4",
|
||||
"workflowRendererVersion": "LG",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
{
|
||||
"id": "legacy-prefix-test-workflow",
|
||||
"revision": 0,
|
||||
"last_node_id": 5,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"pos": [788, 433.5],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["6", "6: 3: string_a"]]
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": [1335, 429],
|
||||
"size": [250, 145.5],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
},
|
||||
"widgets_values": [null, null, false]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [356, 450],
|
||||
"size": [225, 121.5],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Outer\n"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[4, 1, 0, 5, 0, "STRING"],
|
||||
[5, 5, 0, 2, 0, "STRING"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 6,
|
||||
"lastLinkId": 9,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Outer Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [351, 432.5, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1315, 432.5, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"localized_name": "string_a",
|
||||
"pos": [451, 452.5]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1335, 452.5]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [815, 373],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"pos": [955, 775],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "string_a"]]
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [313, 685],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 1\n"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 9,
|
||||
"lastLinkId": 12,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Inner Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [680, 774, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1320, 774, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "string_a",
|
||||
"pos": [780, 794]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [12],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1340, 794]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [860, 719],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [401, 973],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 2\n"]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"pos": [1046, 985],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "string_a"]]
|
||||
},
|
||||
"widgets_values": [""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": 9,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 8,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Innermost Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [262, 1222, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1330, 1222, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "string_a",
|
||||
"pos": [362, 1242]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1350, 1242]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [870, 1038],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [442, 1296],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 3\n"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-7, 144]
|
||||
},
|
||||
"frontendVersion": "1.38.13"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
{
|
||||
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 16,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451.0058559453123, 189.0019842294924],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [25.988896564209426, 473.9973077158204],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
|
||||
"pos": [711.776576770508, 420.55569028417983],
|
||||
"size": [400, 293],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 12
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["7", "text"],
|
||||
["6", "text"],
|
||||
["3", "seed"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[10, 4, 1, 10, 0, "CLIP"],
|
||||
[11, 4, 0, 10, 1, "MODEL"],
|
||||
[12, 4, 2, 10, 2, "VAE"],
|
||||
[13, 10, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 16,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [233, 404.5, 120, 100]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1494, 424.5, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [14],
|
||||
"localized_name": "clip",
|
||||
"pos": [333, 424.5]
|
||||
},
|
||||
{
|
||||
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [333, 444.5]
|
||||
},
|
||||
{
|
||||
"id": "e61199fa-9113-4532-a3d9-879095969171",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"linkIds": [8],
|
||||
"localized_name": "vae",
|
||||
"pos": [333, 464.5]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [9],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1514, 444.5]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [473.007643669922, 609.0214689174805],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [862.990643669922, 185.9853293300783],
|
||||
"size": [400, 317],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209.0062878349609, 188.00400724755877],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
|
||||
"pos": [485.5190761650391, 283.9247189174806],
|
||||
"size": [400, 237],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 14
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [15]
|
||||
},
|
||||
{
|
||||
"localized_name": "CONDITIONING_1",
|
||||
"name": "CONDITIONING_1",
|
||||
"type": "CONDITIONING",
|
||||
"links": [16]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["7", "text"],
|
||||
["6", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 8,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 8,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 16,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [5, 3],
|
||||
"localized_name": "clip",
|
||||
"pos": [55, 20]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "CONDITIONING",
|
||||
"pos": [20, 20]
|
||||
},
|
||||
{
|
||||
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
|
||||
"name": "CONDITIONING_1",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "CONDITIONING_1",
|
||||
"pos": [20, 40]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [413.01228575000005, 388.98593823266606],
|
||||
"size": [425, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [6]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [414.99053247091683, 185.9946096918335],
|
||||
"size": [423, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6830134553650709,
|
||||
"offset": [-203.70966200000038, 259.92420099999975]
|
||||
},
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCompare",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "a_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "b_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCompare"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [50, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
Page
|
||||
} from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
@@ -19,12 +19,10 @@ import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { AssetsHelper } from './helpers/AssetsHelper'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
@@ -42,7 +40,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
@@ -57,7 +55,6 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
@@ -81,11 +78,6 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
}
|
||||
|
||||
get workflowsTab() {
|
||||
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
|
||||
return this._workflowsTab
|
||||
@@ -200,7 +192,6 @@ export class ComfyPage {
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
@@ -247,7 +238,6 @@ export class ComfyPage {
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
}
|
||||
|
||||
@@ -462,13 +452,12 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const needsPerf =
|
||||
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')
|
||||
if (needsPerf) await comfyPage.perf.init()
|
||||
const isPerf = testInfo.tags.includes('@perf')
|
||||
if (isPerf) await comfyPage.perf.init()
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
if (isPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
const comfyMouse = new ComfyMouse(comfyPage)
|
||||
|
||||
@@ -31,14 +31,10 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
* Get locator for a Vue node by the node's title (displayed name in the header)
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
|
||||
})
|
||||
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
@@ -34,20 +33,6 @@ export class ContextMenu {
|
||||
return primeVueVisible || litegraphVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await expect
|
||||
.soft(this.page.getByRole('menuitem', { name: item }))
|
||||
.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
return this
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
||||
const count = await locator.count()
|
||||
|
||||
@@ -168,32 +168,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,17 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from './BuilderStepsHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
}
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
private get builderToolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
@@ -35,6 +24,42 @@ export class AppModeHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Exit builder mode via the footer "Exit app builder" button. */
|
||||
async exitBuilder() {
|
||||
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Inputs" step in the builder toolbar. */
|
||||
async goToInputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Outputs" step in the builder toolbar. */
|
||||
async goToOutputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Preview" step in the builder toolbar. */
|
||||
async goToPreview() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Next" button in the builder footer. */
|
||||
async next() {
|
||||
await this.page.getByRole('button', { name: 'Next' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Back" button in the builder footer. */
|
||||
async back() {
|
||||
await this.page.getByRole('button', { name: 'Back' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Toggle app mode (linear view) on/off. */
|
||||
async toggleAppMode() {
|
||||
await this.page.evaluate(() => {
|
||||
@@ -93,4 +118,84 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder input-select
|
||||
* sidebar (IoItem).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getBuilderInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({ hasText: title })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder preview/arrange
|
||||
* sidebar (AppModeWidgetList with builderMode).
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.locator(`[aria-label="${ariaLabel}"]`)
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private importedFiles: string[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
|
||||
this.generatedJobs = [...jobs]
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = [...this.generatedJobs]
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockOutputHistory([])
|
||||
await this.mockInputFiles([])
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class BuilderFooterHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get nav(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.footerNav)
|
||||
}
|
||||
|
||||
get exitButton(): Locator {
|
||||
return this.buttonByName('Exit app builder')
|
||||
}
|
||||
|
||||
get nextButton(): Locator {
|
||||
return this.buttonByName('Next')
|
||||
}
|
||||
|
||||
get backButton(): Locator {
|
||||
return this.buttonByName('Back')
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
get saveAsChevron(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
}
|
||||
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private buttonByName(name: string): Locator {
|
||||
return this.nav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
async next() {
|
||||
await this.nextButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async back() {
|
||||
await this.backButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async exitBuilder() {
|
||||
await this.exitButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async openSaveAsFromChevron() {
|
||||
await this.saveAsChevron.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Save as' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
get dialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save"]')
|
||||
}
|
||||
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
get successDialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save-success"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.dialog.getByText('Save as')
|
||||
}
|
||||
|
||||
get radioGroup(): Locator {
|
||||
return this.dialog.getByRole('radiogroup')
|
||||
}
|
||||
|
||||
get nameInput(): Locator {
|
||||
return this.dialog.getByRole('textbox')
|
||||
}
|
||||
|
||||
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
||||
return this.dialog.getByRole('radio', { name: viewType })
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.dialog.getByRole('button', { name: 'Save' })
|
||||
}
|
||||
|
||||
get successMessage(): Locator {
|
||||
return this.successDialog.getByText('Successfully saved')
|
||||
}
|
||||
|
||||
get viewAppButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'View app' })
|
||||
}
|
||||
|
||||
get closeButton(): Locator {
|
||||
return this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
}
|
||||
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
get dismissButton(): Locator {
|
||||
return this.successDialog.locator('button.p-dialog-close-button')
|
||||
}
|
||||
|
||||
get exitBuilderButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'Exit builder' })
|
||||
}
|
||||
|
||||
get overwriteDialog(): Locator {
|
||||
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
|
||||
}
|
||||
|
||||
get overwriteButton(): Locator {
|
||||
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
|
||||
}
|
||||
|
||||
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
||||
await this.nameInput.fill(workflowName)
|
||||
await this.viewTypeRadio(viewType).click()
|
||||
await this.saveButton.click()
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the preview/arrange sidebar.
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.getByLabel(ariaLabel, { exact: true })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/** Delete a builder input via its actions menu. */
|
||||
async deleteInput(title: string) {
|
||||
const menu = this.getInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Delete', { exact: true }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title for inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget via its actions popover (works in preview and app mode).
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async selectInputWidget(node: NodeReference) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await this.page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async selectOutputNode() {
|
||||
const saveImageNodeId = await this.page.evaluate(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
const saveImageRef =
|
||||
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await this.page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class BuilderStepsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get toolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToOutputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToPreview() {
|
||||
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -169,39 +169,6 @@ export class CanvasHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan the canvas back and forth in a sweep pattern using middle-mouse drag.
|
||||
* Each step advances one animation frame, giving per-frame measurement
|
||||
* granularity for performance tests.
|
||||
*/
|
||||
async panSweep(options?: {
|
||||
steps?: number
|
||||
dx?: number
|
||||
dy?: number
|
||||
}): Promise<void> {
|
||||
const { steps = 120, dx = 8, dy = 3 } = options ?? {}
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
const centerX = box.x + box.width / 2
|
||||
const centerY = box.y + box.height / 2
|
||||
await this.page.mouse.move(centerX, centerY)
|
||||
await this.page.mouse.down({ button: 'middle' })
|
||||
|
||||
// Sweep forward
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await this.nextFrame()
|
||||
}
|
||||
// Sweep back
|
||||
for (let i = steps; i > 0; i--) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
}
|
||||
|
||||
async disconnectEdge(): Promise<void> {
|
||||
await this.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
|
||||
@@ -25,15 +25,13 @@ export class DragDropHelper {
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
waitForUpload = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
@@ -45,8 +43,7 @@ export class DragDropHelper {
|
||||
fileType?: string
|
||||
buffer?: Uint8Array | number[]
|
||||
url?: string
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
} = { dropPosition }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
@@ -118,17 +115,15 @@ export class DragDropHelper {
|
||||
)
|
||||
}
|
||||
|
||||
if (!params.preserveNativePropagation) {
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
}
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
targetElement.dispatchEvent(dragOverEvent)
|
||||
targetElement.dispatchEvent(dropEvent)
|
||||
@@ -159,10 +154,7 @@ export class DragDropHelper {
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
dropPosition?: Position
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
options: { dropPosition?: Position } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -191,7 +190,6 @@ export class PerformanceHelper {
|
||||
layoutDurationMs: delta('LayoutDuration') * 1000,
|
||||
taskDurationMs: delta('TaskDuration') * 1000,
|
||||
heapDeltaBytes: delta('JSHeapUsedSize'),
|
||||
heapUsedBytes: after.JSHeapUsedSize,
|
||||
domNodes: delta('Nodes'),
|
||||
jsHeapTotalBytes: delta('JSHeapTotalSize'),
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { ConsoleMessage, Locator, Page } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -414,138 +413,4 @@ export class SubgraphHelper {
|
||||
return window.app!.canvas.graph!.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
|
||||
async getSlotCount(type: 'input' | 'output'): Promise<number> {
|
||||
return this.page.evaluate((slotType: 'input' | 'output') => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return 0
|
||||
return graph[`${slotType}s`]?.length ?? 0
|
||||
}, type)
|
||||
}
|
||||
|
||||
async getSlotLabel(
|
||||
type: 'input' | 'output',
|
||||
index = 0
|
||||
): Promise<string | null> {
|
||||
return this.page.evaluate(
|
||||
([slotType, idx]) => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const slot = graph[`${slotType}s`]?.[idx]
|
||||
return slot?.label ?? slot?.name ?? null
|
||||
},
|
||||
[type, index] as const
|
||||
)
|
||||
}
|
||||
|
||||
async removeSlot(type: 'input' | 'output', slotName?: string): Promise<void> {
|
||||
if (type === 'input') {
|
||||
await this.rightClickInputSlot(slotName)
|
||||
} else {
|
||||
await this.rightClickOutputSlot(slotName)
|
||||
}
|
||||
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async findSubgraphNodeId(): Promise<string> {
|
||||
const id = await this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const node = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!id) throw new Error('No subgraph node found in current graph')
|
||||
return id
|
||||
}
|
||||
|
||||
async serializeAndReload(): Promise<void> {
|
||||
const serialized = await this.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await this.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized as ComfyWorkflowJSON
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
|
||||
await this.comfyPage.workflow.loadWorkflow('default')
|
||||
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await this.comfyPage.nextFrame()
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
|
||||
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
|
||||
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.graph!.convertToSubgraph(canvas.selectedItems)
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.exitViaBreadcrumb()
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
static getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
|
||||
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input.widget || input.type !== 'STRING') continue
|
||||
return {
|
||||
hasPos: !!input.pos,
|
||||
posY: input.pos?.[1] ?? null,
|
||||
widgetName: input.widget.name,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
static async expectWidgetBelowHeader(
|
||||
nodeLocator: Locator,
|
||||
widgetLocator: Locator
|
||||
): Promise<void> {
|
||||
const headerBox = await nodeLocator
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await widgetLocator.boundingBox()
|
||||
if (!headerBox || !widgetBox)
|
||||
throw new Error('Header or widget bounding box not found')
|
||||
expect(widgetBox.y).toBeGreaterThan(headerBox.y + headerBox.height)
|
||||
}
|
||||
|
||||
static collectConsoleWarnings(
|
||||
page: Page,
|
||||
patterns: string[] = [
|
||||
'No link found',
|
||||
'Failed to resolve legacy -1',
|
||||
'No inner link found'
|
||||
]
|
||||
): { warnings: string[]; dispose: () => void } {
|
||||
const warnings: string[] = []
|
||||
const handler = (msg: ConsoleMessage) => {
|
||||
const text = msg.text()
|
||||
if (patterns.some((p) => text.includes(p))) {
|
||||
warnings.push(text)
|
||||
}
|
||||
}
|
||||
page.on('console', handler)
|
||||
return { warnings, dispose: () => page.off('console', handler) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import type { AppMode } from '../../../src/composables/useAppMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
@@ -105,40 +104,6 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowPath(): Promise<string | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.path
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowActiveAppMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.activeMode
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowInitialMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.initialMode
|
||||
})
|
||||
}
|
||||
|
||||
async getLinearModeFromGraph(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return window.app!.rootGraph.extra?.linearMode as boolean | undefined
|
||||
})
|
||||
}
|
||||
|
||||
async getOpenWorkflowCount(): Promise<number> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow.workflows
|
||||
.length
|
||||
})
|
||||
}
|
||||
|
||||
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
|
||||
@@ -20,12 +20,7 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
zoomOutAction: 'zoom-out-action',
|
||||
zoomToFitAction: 'zoom-to-fit-action',
|
||||
zoomPercentageInput: 'zoom-percentage-input'
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
|
||||
},
|
||||
dialogs: {
|
||||
settings: 'settings-dialog',
|
||||
@@ -34,8 +29,6 @@ export const TestIds = {
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
errorOverlayMessages: 'error-overlay-messages',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
@@ -51,8 +44,7 @@ export const TestIds = {
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button'
|
||||
saveButton: 'save-workflow-button'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
@@ -70,22 +62,15 @@ export const TestIds = {
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
container: 'node-widgets',
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
saveAsButton: 'builder-save-as-button',
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
|
||||
@@ -392,11 +392,6 @@ export class NodeReference {
|
||||
await this.comfyPage.clipboard.copy()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
await this.click('title')
|
||||
await this.comfyPage.page.keyboard.press('Delete')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async connectWidget(
|
||||
originSlotIndex: number,
|
||||
targetNode: NodeReference,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user