Compare commits
65 Commits
qpo-progre
...
test/asset
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa47be0598 | ||
|
|
b4ddb07166 | ||
|
|
bbb12e35ea | ||
|
|
2f9431c6dd | ||
|
|
62979e3818 | ||
|
|
6e249f2e05 | ||
|
|
a1c46d7086 | ||
|
|
dd89b74ca5 | ||
|
|
e809d74192 | ||
|
|
47c9a027a7 | ||
|
|
6fbc5723bd | ||
|
|
db6e5062f2 | ||
|
|
6da5d26980 | ||
|
|
9b6b762a97 | ||
|
|
00c8c11288 | ||
|
|
668f7e48e7 | ||
|
|
3de387429a | ||
|
|
08b1199265 | ||
|
|
98a9facc7d | ||
|
|
8b1cf594d1 | ||
|
|
ff263fced0 | ||
|
|
3e197b5c57 | ||
|
|
9573074ea6 | ||
|
|
68d47af075 | ||
|
|
897cf9cb8f | ||
|
|
e9b01cf479 | ||
|
|
bcb39b1bf6 | ||
|
|
d940ea76ee | ||
|
|
d397318ad8 | ||
|
|
d860d54366 | ||
|
|
86d202bcc1 | ||
|
|
60b6f78397 | ||
|
|
6c7c3ea006 | ||
|
|
771f68f92a | ||
|
|
c5e9e52e5f | ||
|
|
fa1ffcba01 | ||
|
|
f1db1122f3 | ||
|
|
f56abb3ecf | ||
|
|
95c6811f59 | ||
|
|
88079250eb | ||
|
|
08ea013c51 | ||
|
|
4aae52c2fc | ||
|
|
6b7691422b | ||
|
|
437f41c553 | ||
|
|
975393b48b | ||
|
|
a44fa1fdd5 | ||
|
|
cc3acebceb | ||
|
|
23c22e4c52 | ||
|
|
88b54c6775 | ||
|
|
e40995fb6c | ||
|
|
908a3ea418 | ||
|
|
0ae4b78cbc | ||
|
|
5f14276159 | ||
|
|
df81c66725 | ||
|
|
c5ad6cf1aa | ||
|
|
fe1fae4de1 | ||
|
|
38aad02fb9 | ||
|
|
7c4234cf93 | ||
|
|
4bfc730a0e | ||
|
|
001916edf6 | ||
|
|
66daa6d645 | ||
|
|
32a53eeaee | ||
|
|
7c6ab19484 | ||
|
|
8f9fe3b21e | ||
|
|
4411d9a417 |
118
.agents/checks/adr-compliance.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
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
|
||||
94
.claude/commands/adr-compliance-audit.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 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
|
||||
@@ -18,12 +18,20 @@ 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 | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
| 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.
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
@@ -108,11 +116,15 @@ 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
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
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.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
|
||||
@@ -19,23 +19,44 @@ done
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
> **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:
|
||||
|
||||
```bash
|
||||
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
|
||||
# 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
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
@@ -63,6 +84,13 @@ 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
|
||||
@@ -82,7 +110,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
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
@@ -132,7 +160,8 @@ 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
|
||||
gh pr merge $PR --squash --admin
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
@@ -146,5 +175,31 @@ 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` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **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 | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
| PR# | Title | CI Status | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
@@ -19,6 +19,7 @@ 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)
|
||||
```
|
||||
@@ -41,6 +42,11 @@ 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 |
|
||||
|
||||
99
.claude/skills/contain-audit/SKILL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
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,3 +28,21 @@ 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.
|
||||
|
||||
4
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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 -10); do
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); 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.13
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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, 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)
|
||||
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)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
@@ -231,6 +231,18 @@ 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,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } 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'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
|
||||
2
apps/website/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
.astro/
|
||||
24
apps/website/astro.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
integrations: [vue()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
build: {
|
||||
assetsPrefix: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: undefined
|
||||
},
|
||||
i18n: {
|
||||
locales: ['en', 'zh-CN'],
|
||||
defaultLocale: 'en',
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
}
|
||||
})
|
||||
80
apps/website/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@comfyorg/website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:website",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro build"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro check"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/website/public/fonts/inter-latin-italic.woff2
Normal file
BIN
apps/website/public/fonts/inter-latin-normal.woff2
Normal file
1
apps/website/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
2
apps/website/src/styles/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
9
apps/website/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
}
|
||||
@@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example
|
||||
**Always use UI mode for development:**
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --ui
|
||||
pnpm test:browser:local --ui
|
||||
```
|
||||
|
||||
UI mode features:
|
||||
@@ -91,29 +91,8 @@ UI mode features:
|
||||
For CI or headless testing:
|
||||
|
||||
```bash
|
||||
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')
|
||||
},
|
||||
|
||||
})
|
||||
pnpm test:browser:local # Run all tests
|
||||
pnpm test:browser:local widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
@@ -385,7 +364,7 @@ export default defineConfig({
|
||||
Option 2 - Generate local baselines for comparison:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --update-snapshots
|
||||
pnpm test:browser:local --update-snapshots
|
||||
```
|
||||
|
||||
### Creating New Screenshot Baselines
|
||||
|
||||
817
browser_tests/assets/subgraphs/nested-pack-promoted-values.json
Normal file
@@ -0,0 +1,817 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
48
browser_tests/assets/widgets/painter_widget.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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 dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
@@ -19,10 +19,12 @@ 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'
|
||||
@@ -40,7 +42,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
@@ -55,6 +57,7 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
@@ -78,6 +81,11 @@ 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
|
||||
@@ -192,6 +200,7 @@ 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 */
|
||||
@@ -238,6 +247,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -452,12 +462,13 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const isPerf = testInfo.tags.includes('@perf')
|
||||
if (isPerf) await comfyPage.perf.init()
|
||||
const needsPerf =
|
||||
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')
|
||||
if (needsPerf) await comfyPage.perf.init()
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
if (isPerf) await comfyPage.perf.dispose()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
const comfyMouse = new ComfyMouse(comfyPage)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -168,3 +169,190 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
// --- Empty state ---
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
// Matches both normal ("Delete" text) and compact (icon-only) modes
|
||||
return this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
// Matches both normal ("Download" text) and compact (icon-only) modes
|
||||
return this.page.locator('button:has(.icon-\\[lucide--download\\])').last()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Remove all visible toast notifications from the DOM. */
|
||||
async dismissToasts() {
|
||||
await this.page
|
||||
.evaluate(() =>
|
||||
document.querySelectorAll('.p-toast').forEach((el) => el.remove())
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async switchToImported() {
|
||||
await this.dismissToasts()
|
||||
await this.importedTab.evaluate((el: HTMLElement) => el.click())
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async switchToGenerated() {
|
||||
await this.dismissToasts()
|
||||
await this.generatedTab.evaluate((el: HTMLElement) => el.click())
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async openSettingsMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.settingsButton.click({ force: true })
|
||||
// Wait for popover content to render
|
||||
await this.listViewOption
|
||||
.or(this.gridViewOption)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async waitForAssets(count?: number) {
|
||||
if (count !== undefined) {
|
||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
||||
} else {
|
||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
204
browser_tests/fixtures/helpers/AssetsHelper.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
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(?:\?.*)?$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now() / 1000
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now() / 1000
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60,
|
||||
execution_start_time: now - i * 60,
|
||||
execution_end_time: now - i * 60 + 5 + i,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,12 @@ export class CanvasHelper {
|
||||
await this.page.mouse.move(10, 10)
|
||||
}
|
||||
|
||||
async isReadOnly(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.state.readOnly
|
||||
})
|
||||
}
|
||||
|
||||
async getScale(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.ds.scale
|
||||
|
||||
@@ -25,13 +25,15 @@ export class DragDropHelper {
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
url,
|
||||
waitForUpload = false
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
@@ -43,7 +45,8 @@ export class DragDropHelper {
|
||||
fileType?: string
|
||||
buffer?: Uint8Array | number[]
|
||||
url?: string
|
||||
} = { dropPosition }
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
@@ -115,15 +118,17 @@ export class DragDropHelper {
|
||||
)
|
||||
}
|
||||
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
if (!params.preserveNativePropagation) {
|
||||
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)
|
||||
@@ -154,7 +159,10 @@ export class DragDropHelper {
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: { dropPosition?: Position } = {}
|
||||
options: {
|
||||
dropPosition?: Position
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -190,6 +191,7 @@ 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,
|
||||
|
||||
@@ -20,7 +20,12 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-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'
|
||||
},
|
||||
dialogs: {
|
||||
settings: 'settings-dialog',
|
||||
@@ -28,10 +33,17 @@ export const TestIds = {
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
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',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -57,6 +69,8 @@ export const TestIds = {
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
container: 'node-widgets',
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
@@ -76,6 +90,10 @@ export const TestIds = {
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -101,3 +119,4 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { backupPath } from './utils/backupUtils'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
export default function globalSetup(_config: FullConfig) {
|
||||
export default function globalSetup() {
|
||||
if (!process.env.CI) {
|
||||
if (process.env.TEST_COMFYUI_DIR) {
|
||||
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
export default function globalTeardown(_config: FullConfig) {
|
||||
export default function globalTeardown() {
|
||||
writePerfReport()
|
||||
|
||||
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||
|
||||
@@ -38,16 +38,13 @@ const customColorPalettes = {
|
||||
CLEAR_BACKGROUND_COLOR: '#222222',
|
||||
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
|
||||
NODE_SELECTED_TITLE_COLOR: '#FFF',
|
||||
NODE_TEXT_SIZE: 14,
|
||||
NODE_TEXT_COLOR: '#b8b8b8',
|
||||
NODE_SUBTEXT_SIZE: 12,
|
||||
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
|
||||
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
|
||||
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
|
||||
NODE_DEFAULT_SHAPE: 'box',
|
||||
NODE_BOX_OUTLINE_COLOR: '#236692',
|
||||
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
|
||||
DEFAULT_GROUP_FONT: 24,
|
||||
WIDGET_BGCOLOR: '#242424',
|
||||
WIDGET_OUTLINE_COLOR: '#333',
|
||||
WIDGET_TEXT_COLOR: '#a3a3a8',
|
||||
@@ -102,16 +99,13 @@ const customColorPalettes = {
|
||||
CLEAR_BACKGROUND_COLOR: '#000',
|
||||
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
|
||||
NODE_SELECTED_TITLE_COLOR: '#FFF',
|
||||
NODE_TEXT_SIZE: 14,
|
||||
NODE_TEXT_COLOR: '#b8b8b8',
|
||||
NODE_SUBTEXT_SIZE: 12,
|
||||
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
|
||||
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
|
||||
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
|
||||
NODE_DEFAULT_SHAPE: 'box',
|
||||
NODE_BOX_OUTLINE_COLOR: '#236692',
|
||||
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
|
||||
DEFAULT_GROUP_FONT: 24,
|
||||
WIDGET_BGCOLOR: '#242424',
|
||||
WIDGET_OUTLINE_COLOR: '#333',
|
||||
WIDGET_TEXT_COLOR: '#a3a3a8',
|
||||
|
||||
276
browser_tests/tests/containAudit.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
interface ContainCandidate {
|
||||
selector: string
|
||||
testId: string | null
|
||||
tagName: string
|
||||
className: string
|
||||
subtreeSize: number
|
||||
hasFixedWidth: boolean
|
||||
isFlexChild: boolean
|
||||
hasExplicitDimensions: boolean
|
||||
alreadyContained: boolean
|
||||
score: number
|
||||
}
|
||||
|
||||
interface AuditResult {
|
||||
candidate: ContainCandidate
|
||||
baseline: Pick<PerfMeasurement, 'styleRecalcs' | 'layouts' | 'taskDurationMs'>
|
||||
withContain: Pick<
|
||||
PerfMeasurement,
|
||||
'styleRecalcs' | 'layouts' | 'taskDurationMs'
|
||||
>
|
||||
deltaRecalcsPct: number
|
||||
deltaLayoutsPct: number
|
||||
visuallyBroken: boolean
|
||||
}
|
||||
|
||||
function formatPctDelta(value: number): string {
|
||||
const sign = value >= 0 ? '+' : ''
|
||||
return `${sign}${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function pctChange(baseline: number, measured: number): number {
|
||||
if (baseline === 0) return 0
|
||||
return ((measured - baseline) / baseline) * 100
|
||||
}
|
||||
|
||||
const STABILIZATION_FRAMES = 60
|
||||
const SETTLE_FRAMES = 10
|
||||
|
||||
test.describe('CSS Containment Audit', { tag: ['@audit'] }, () => {
|
||||
test('scan large graph for containment candidates', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
|
||||
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Walk the DOM and find candidates
|
||||
const candidates = await comfyPage.page.evaluate((): ContainCandidate[] => {
|
||||
const results: ContainCandidate[] = []
|
||||
|
||||
const graphContainer =
|
||||
document.querySelector('.graph-canvas-container') ??
|
||||
document.querySelector('[class*="comfy-vue-node"]')?.parentElement ??
|
||||
document.querySelector('.lg-node')?.parentElement
|
||||
|
||||
const root = graphContainer ?? document.body
|
||||
const allElements = root.querySelectorAll('*')
|
||||
|
||||
allElements.forEach((el) => {
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
|
||||
const subtreeSize = el.querySelectorAll('*').length
|
||||
if (subtreeSize < 5) return
|
||||
|
||||
const computed = getComputedStyle(el)
|
||||
|
||||
const containValue = computed.contain || 'none'
|
||||
const alreadyContained =
|
||||
containValue.includes('layout') || containValue.includes('strict')
|
||||
|
||||
const hasFixedWidth =
|
||||
computed.width !== 'auto' &&
|
||||
!computed.width.includes('%') &&
|
||||
computed.width !== '0px'
|
||||
|
||||
const isFlexChild =
|
||||
el.parentElement !== null &&
|
||||
getComputedStyle(el.parentElement).display.includes('flex') &&
|
||||
(computed.flexGrow !== '0' || computed.flexShrink !== '1')
|
||||
|
||||
const hasExplicitDimensions =
|
||||
hasFixedWidth ||
|
||||
(computed.minWidth !== '0px' && computed.minWidth !== 'auto') ||
|
||||
(computed.maxWidth !== 'none' && computed.maxWidth !== '0px')
|
||||
|
||||
let score = subtreeSize
|
||||
if (hasExplicitDimensions) score *= 2
|
||||
if (isFlexChild) score *= 1.5
|
||||
if (alreadyContained) score = 0
|
||||
|
||||
let selector = el.tagName.toLowerCase()
|
||||
const testId = el.getAttribute('data-testid')
|
||||
if (testId) {
|
||||
selector = `[data-testid="${testId}"]`
|
||||
} else if (el.id) {
|
||||
selector = `#${el.id}`
|
||||
} else if (el.parentElement) {
|
||||
// Use nth-child to disambiguate instead of fragile first-class fallback
|
||||
// (e.g. Tailwind utilities like .flex, .relative are shared across many elements)
|
||||
const children = Array.from(el.parentElement.children)
|
||||
const index = children.indexOf(el) + 1
|
||||
const parentTestId = el.parentElement.getAttribute('data-testid')
|
||||
if (parentTestId) {
|
||||
selector = `[data-testid="${parentTestId}"] > :nth-child(${index})`
|
||||
} else if (el.parentElement.id) {
|
||||
selector = `#${el.parentElement.id} > :nth-child(${index})`
|
||||
} else {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
selector = `${tag}:nth-child(${index})`
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
selector,
|
||||
testId,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
className:
|
||||
typeof el.className === 'string' ? el.className.slice(0, 80) : '',
|
||||
subtreeSize,
|
||||
hasFixedWidth,
|
||||
isFlexChild,
|
||||
hasExplicitDimensions,
|
||||
alreadyContained,
|
||||
score
|
||||
})
|
||||
})
|
||||
|
||||
results.sort((a, b) => b.score - a.score)
|
||||
return results.slice(0, 20)
|
||||
})
|
||||
|
||||
console.log(`\nFound ${candidates.length} containment candidates\n`)
|
||||
|
||||
// Deduplicate candidates by selector (keep highest score)
|
||||
const seen = new Set<string>()
|
||||
const uniqueCandidates = candidates.filter((c) => {
|
||||
if (seen.has(c.selector)) return false
|
||||
seen.add(c.selector)
|
||||
return true
|
||||
})
|
||||
|
||||
// Measure baseline performance (idle)
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const baseline = await comfyPage.perf.stopMeasuring('baseline-idle')
|
||||
|
||||
// Take a baseline screenshot for visual comparison
|
||||
const baselineScreenshot = await comfyPage.page.screenshot()
|
||||
|
||||
// For each candidate, apply contain and measure
|
||||
const results: AuditResult[] = []
|
||||
|
||||
const testCandidates = uniqueCandidates
|
||||
.filter((c) => !c.alreadyContained && c.score > 0)
|
||||
.slice(0, 10)
|
||||
|
||||
for (const candidate of testCandidates) {
|
||||
const applied = await comfyPage.page.evaluate((sel: string) => {
|
||||
const elements = document.querySelectorAll(sel)
|
||||
let count = 0
|
||||
elements.forEach((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
el.style.contain = 'layout style'
|
||||
count++
|
||||
}
|
||||
})
|
||||
return count
|
||||
}, candidate.selector)
|
||||
|
||||
if (applied === 0) continue
|
||||
|
||||
for (let i = 0; i < SETTLE_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Measure with containment
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const withContain = await comfyPage.perf.stopMeasuring(
|
||||
`contain-${candidate.selector}`
|
||||
)
|
||||
|
||||
// Take screenshot with containment applied to detect visual breakage.
|
||||
// Note: PNG byte comparison can produce false positives from subpixel
|
||||
// rendering and anti-aliasing. Treat "DIFF" as "needs manual review".
|
||||
const containScreenshot = await comfyPage.page.screenshot()
|
||||
const visuallyBroken = !baselineScreenshot.equals(containScreenshot)
|
||||
|
||||
// Remove containment
|
||||
await comfyPage.page.evaluate((sel: string) => {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
el.style.contain = ''
|
||||
}
|
||||
})
|
||||
}, candidate.selector)
|
||||
|
||||
for (let i = 0; i < SETTLE_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
results.push({
|
||||
candidate,
|
||||
baseline: {
|
||||
styleRecalcs: baseline.styleRecalcs,
|
||||
layouts: baseline.layouts,
|
||||
taskDurationMs: baseline.taskDurationMs
|
||||
},
|
||||
withContain: {
|
||||
styleRecalcs: withContain.styleRecalcs,
|
||||
layouts: withContain.layouts,
|
||||
taskDurationMs: withContain.taskDurationMs
|
||||
},
|
||||
deltaRecalcsPct: pctChange(
|
||||
baseline.styleRecalcs,
|
||||
withContain.styleRecalcs
|
||||
),
|
||||
deltaLayoutsPct: pctChange(baseline.layouts, withContain.layouts),
|
||||
visuallyBroken
|
||||
})
|
||||
}
|
||||
|
||||
// Print the report
|
||||
const divider = '='.repeat(100)
|
||||
const thinDivider = '-'.repeat(100)
|
||||
console.log('\n')
|
||||
console.log('CSS Containment Audit Results')
|
||||
console.log(divider)
|
||||
console.log(
|
||||
'Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual'
|
||||
)
|
||||
console.log(thinDivider)
|
||||
|
||||
results
|
||||
.sort((a, b) => a.deltaRecalcsPct - b.deltaRecalcsPct)
|
||||
.forEach((r, i) => {
|
||||
const sel = r.candidate.selector.padEnd(42)
|
||||
const sub = String(r.candidate.subtreeSize).padStart(7)
|
||||
const score = String(Math.round(r.candidate.score)).padStart(5)
|
||||
const dr = formatPctDelta(r.deltaRecalcsPct)
|
||||
const dl = formatPctDelta(r.deltaLayoutsPct)
|
||||
const vis = r.visuallyBroken ? 'DIFF' : 'OK'
|
||||
console.log(
|
||||
` ${String(i + 1).padStart(2)} | ${sel} | ${sub} | ${score} | ${dr.padStart(10)} | ${dl.padStart(10)} | ${vis}`
|
||||
)
|
||||
})
|
||||
|
||||
console.log(divider)
|
||||
console.log(
|
||||
`\nBaseline: ${baseline.styleRecalcs} style recalcs, ${baseline.layouts} layouts, ${baseline.taskDurationMs.toFixed(1)}ms task duration\n`
|
||||
)
|
||||
|
||||
const alreadyContained = uniqueCandidates.filter((c) => c.alreadyContained)
|
||||
if (alreadyContained.length > 0) {
|
||||
console.log('Already contained elements:')
|
||||
alreadyContained.forEach((c) => {
|
||||
console.log(` ${c.selector} (subtree: ${c.subtreeSize})`)
|
||||
})
|
||||
}
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Pan interaction perf measurement removed — covered by PR #10001 (performance.spec.ts).
|
||||
// The containment fix itself is tracked in PR #9946.
|
||||
})
|
||||
318
browser_tests/tests/defaultKeybindings.spec.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
async function pressKeyAndExpectRequest(
|
||||
comfyPage: ComfyPage,
|
||||
key: string,
|
||||
urlPattern: string,
|
||||
method: string = 'POST'
|
||||
) {
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().includes(urlPattern) && req.method() === method,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
await comfyPage.page.keyboard.press(key)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test.describe('Sidebar Toggle Shortcuts', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const sidebarTabs = [
|
||||
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
|
||||
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
|
||||
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
|
||||
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
|
||||
] as const
|
||||
|
||||
for (const { key, tabId, label } of sidebarTabs) {
|
||||
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
|
||||
const selectedButton = comfyPage.page.locator(
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Canvas View Controls', () => {
|
||||
test("'Alt+=' zooms in", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Equal')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeGreaterThan(initialScale)
|
||||
})
|
||||
|
||||
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvas.press('Alt+Minus')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeLessThan(initialScale)
|
||||
})
|
||||
|
||||
test("'.' fits view to nodes", async ({ comfyPage }) => {
|
||||
// Set scale very small so fit-view will zoom back to fit nodes
|
||||
await comfyPage.canvasOps.setScale(0.1)
|
||||
const scaleBefore = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleBefore).toBeCloseTo(0.1, 1)
|
||||
|
||||
// Click canvas to ensure focus is within graph-canvas-container
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.press('Period')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfter = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfter).toBeGreaterThan(scaleBefore)
|
||||
})
|
||||
|
||||
test("'h' locks canvas", async ({ comfyPage }) => {
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
})
|
||||
|
||||
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
||||
// Lock first
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node State Toggles', () => {
|
||||
test("'Alt+c' collapses and expands selected nodes", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await node.isCollapsed()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
})
|
||||
|
||||
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Normal mode is ALWAYS (0)
|
||||
const getMode = () =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
|
||||
}, node.id)
|
||||
|
||||
expect(await getMode()).toBe(0)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
// NEVER (2) = muted
|
||||
expect(await getMode()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getMode()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mode and Panel Toggles', () => {
|
||||
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Set up linearData so app mode has something to show
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
// Toggle off with Alt+m
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
|
||||
|
||||
// Toggle on again
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Queue and Execution', () => {
|
||||
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Enter',
|
||||
'/prompt',
|
||||
'POST'
|
||||
)
|
||||
expect(request.url()).toContain('/prompt')
|
||||
})
|
||||
|
||||
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Shift+Enter',
|
||||
'/prompt',
|
||||
'POST'
|
||||
)
|
||||
const body = request.postDataJSON()
|
||||
expect(body.front).toBe(true)
|
||||
})
|
||||
|
||||
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'Control+Alt+Enter',
|
||||
'/interrupt',
|
||||
'POST'
|
||||
)
|
||||
expect(request.url()).toContain('/interrupt')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('File Operations', () => {
|
||||
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
|
||||
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.page.keyboard.press('Control+s')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
|
||||
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
|
||||
// Detect the file input click via an event listener.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.TestCommand = false
|
||||
const fileInputs =
|
||||
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
|
||||
for (const input of fileInputs) {
|
||||
input.addEventListener('click', () => {
|
||||
window.TestCommand = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+o')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Graph Operations', () => {
|
||||
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBeGreaterThan(1)
|
||||
|
||||
// Select all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// After conversion, node count should decrease
|
||||
// (multiple nodes replaced by single subgraph node)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
test("'r' refreshes node definitions", async ({ comfyPage }) => {
|
||||
const request = await pressKeyAndExpectRequest(
|
||||
comfyPage,
|
||||
'KeyR',
|
||||
'/object_info',
|
||||
'GET'
|
||||
)
|
||||
expect(request.url()).toContain('/object_info')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -28,8 +28,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing.*installed/i)
|
||||
})
|
||||
|
||||
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
|
||||
@@ -42,11 +45,16 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing.*installed/i)
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
@@ -75,7 +83,9 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the right side panel errors tab
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify MissingNodeCard is rendered in the errors tab
|
||||
@@ -98,7 +108,7 @@ test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Make a change to the graph by moving a node
|
||||
@@ -165,17 +175,19 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify Find on GitHub button is present in the error card
|
||||
const findOnGithubButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Find on GitHub'
|
||||
})
|
||||
const findOnGithubButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorCardFindOnGithub
|
||||
)
|
||||
await expect(findOnGithubButton).toBeVisible()
|
||||
|
||||
// Verify Copy button is present in the error card
|
||||
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
|
||||
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
|
||||
await expect(copyButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -204,8 +216,11 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
})
|
||||
|
||||
test('Should show missing models from node properties', async ({
|
||||
@@ -220,8 +235,11 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
})
|
||||
|
||||
test('Should not show missing models when widget values have changed', async ({
|
||||
@@ -231,13 +249,12 @@ test.describe('Missing models in Error Tab', () => {
|
||||
'missing/model_metadata_widget_mismatch'
|
||||
)
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -29,14 +30,14 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Error overlay shows error message', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
@@ -44,10 +45,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
@@ -55,10 +56,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
@@ -68,10 +69,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /Dismiss/i }).click()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
@@ -82,7 +83,7 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -24,16 +25,14 @@ test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.getByRole('button', { name: 'Dismiss' })
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.waitFor({ state: 'hidden' })
|
||||
await errorOverlay.waitFor({ state: 'hidden' })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'execution-error-unconnected-slot.png'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -10,6 +10,7 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -720,6 +721,19 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
|
||||
})
|
||||
|
||||
test('Creates initial workflow tab when persistence is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
const openCount = await comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.openWorkflows.length
|
||||
})
|
||||
expect(openCount).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Restore workflow on reload (switch workflow)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -764,13 +778,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.describe('Restore all open workflows on reload', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
@@ -829,6 +843,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Restore workflow tabs after browser restart', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
workflowA = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowA)
|
||||
workflowB = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage fallback pointers to be written
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Simulate browser restart: clear sessionStorage (lost on close)
|
||||
// but keep localStorage (survives browser restart)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
})
|
||||
|
||||
test('Restores topbar workflow tabs after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
// Wait for both restored tabs to render (localStorage fallback is async)
|
||||
await expect(
|
||||
comfyPage.page.locator('.workflow-tabs .workflow-label', {
|
||||
hasText: workflowA
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
test('Restores sidebar workflows after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
const openWorkflows =
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
const activeWorkflowName =
|
||||
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
|
||||
expect(openWorkflows).toEqual(
|
||||
expect.arrayContaining([workflowA, workflowB])
|
||||
)
|
||||
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowB)
|
||||
)
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
})
|
||||
|
||||
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.EnableWorkflowViewRestore',
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 72 KiB |
@@ -67,5 +67,44 @@ test.describe(
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('Load workflow from URL dropped onto Vue node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fakeUrl = 'https://example.com/workflow.png'
|
||||
await comfyPage.page.route(fakeUrl, (route) =>
|
||||
route.fulfill({
|
||||
path: comfyPage.assetPath('workflowInMedia/workflow_itxt.png')
|
||||
})
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
|
||||
const dropPosition = {
|
||||
x: box!.x + box!.width / 2,
|
||||
y: box!.y + box!.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
|
||||
dropPosition,
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
|
||||
await comfyPage.page.waitForFunction(
|
||||
(prevCount) => window.app!.graph.nodes.length !== prevCount,
|
||||
initialNodeCount,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newNodeCount).not.toBe(initialNodeCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
92
browser_tests/tests/painter.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Painter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Renders canvas and controls',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
await expect(painterWidget).toBeVisible()
|
||||
|
||||
await expect(painterWidget.locator('canvas')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Brush')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Eraser')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Clear')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.locator('input[type="color"]').first()
|
||||
).toBeVisible()
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-default-state.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Drawing a stroke changes the canvas',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const isEmptyBefore = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return true
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
|
||||
})
|
||||
expect(isEmptyBefore).toBe(true)
|
||||
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width * 0.3,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width * 0.7,
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const hasContent = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasContent).toBe(true)
|
||||
}).toPass()
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -154,6 +154,38 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('large graph zoom interaction', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Position mouse at center so wheel events hit the canvas
|
||||
const centerX = box.x + box.width / 2
|
||||
const centerY = box.y + box.height / 2
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Zoom in 30 steps then out 30 steps — each step triggers
|
||||
// ResizeObserver for all ~245 node elements due to CSS scale change.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await comfyPage.page.mouse.wheel(0, -100)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await comfyPage.page.mouse.wheel(0, 100)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('large-graph-zoom')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Large graph zoom: ${m.layouts} layouts, ${m.layoutDurationMs.toFixed(1)}ms layout, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
test('subgraph DOM widget clipping during node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
670
browser_tests/tests/sidebar/assets.spec.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '../../fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
]
|
||||
|
||||
const SAMPLE_IMPORTED_FILES = [
|
||||
'reference_photo.png',
|
||||
'background.jpg',
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('Can switch between Generated and Imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.waitForAssets()
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Displays imported files when switching to Imported tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Switch back to grid by setting localStorage and refreshing the panel
|
||||
await comfyPage.page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'Comfy.Assets.Sidebar.ViewMode',
|
||||
JSON.stringify('grid')
|
||||
)
|
||||
})
|
||||
// Close and reopen sidebar to pick up the localStorage change
|
||||
await tab.close()
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filtering assets by search query reduces displayed count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect(async () => {
|
||||
const filteredCount = await tab.assetCards.count()
|
||||
expect(filteredCount).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(async () => {
|
||||
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||
await cards.nth(1).click({ modifiers: [modifier] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Selection shows footer with count and actions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Context menu contains Download action for output asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Download')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Inspect action for image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Delete action for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Delete')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Copy job ID for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains workflow actions for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
|
||||
await expect(
|
||||
tab.contextMenuItem('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bulk context menu shows when multiple assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Multi-select: click first, then Ctrl/Cmd+click second
|
||||
await cards.first().click({ force: true })
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||
await cards.nth(1).click({ modifiers: [modifier], force: true })
|
||||
|
||||
// Verify multi-selection took effect before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
|
||||
// Right-click on a selected card via dispatchEvent to ensure contextmenu fires
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Footer shows delete button when output assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select two assets
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await cards.first().click()
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||
await cards.nth(1).click({ modifiers: [modifier] })
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
const text = await tab.selectionCountButton.textContent()
|
||||
expect(text).toMatch(/Assets Selected: \d+/)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Initially loads a batch of assets with has_more pagination', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Create a large set of jobs to trigger pagination
|
||||
const manyJobs = createMockJobs(30)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Should load at least the first batch
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
122
browser_tests/tests/sidebar/sidebarSplitterWidth.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Sidebar splitter width independence', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.UnifiedWidth', true)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
})
|
||||
|
||||
async function dismissToasts(comfyPage: ComfyPage) {
|
||||
const buttons = await comfyPage.page.locator('.p-toast-close-button').all()
|
||||
for (const btn of buttons) {
|
||||
await btn.click({ timeout: 2000 }).catch(() => {})
|
||||
}
|
||||
// Brief wait for animations
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function dragGutter(comfyPage: ComfyPage, deltaX: number) {
|
||||
const gutter = comfyPage.page
|
||||
.locator('.p-splitter-gutter:not(.hidden)')
|
||||
.first()
|
||||
await expect(gutter).toBeVisible()
|
||||
const box = await gutter.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
const centerX = box!.x + box!.width / 2
|
||||
const centerY = box!.y + box!.height / 2
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(centerX + deltaX, centerY, { steps: 10 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function openSidebarAt(
|
||||
comfyPage: ComfyPage,
|
||||
location: 'left' | 'right'
|
||||
) {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
|
||||
await comfyPage.nextFrame()
|
||||
await dismissToasts(comfyPage)
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
}
|
||||
|
||||
test('left and right sidebars use separate localStorage keys', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open sidebar on the left and resize it
|
||||
await openSidebarAt(comfyPage, 'left')
|
||||
await dragGutter(comfyPage, 100)
|
||||
|
||||
// Read the sidebar panel width after resize
|
||||
const leftSidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
const leftWidth = (await leftSidebar.boundingBox())!.width
|
||||
|
||||
// Close sidebar, switch to right, open again
|
||||
await comfyPage.menu.nodeLibraryTab.close()
|
||||
await openSidebarAt(comfyPage, 'right')
|
||||
|
||||
// Right sidebar should use its default width, not the left's resized width
|
||||
const rightSidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
await expect(rightSidebar).toBeVisible()
|
||||
const rightWidth = (await rightSidebar.boundingBox())!.width
|
||||
|
||||
// The right sidebar should NOT match the left's resized width.
|
||||
// We dragged the left sidebar 100px wider, so there should be a noticeable
|
||||
// difference between the left (resized) and right (default) widths.
|
||||
expect(Math.abs(rightWidth - leftWidth)).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
test('localStorage keys include sidebar location', async ({ comfyPage }) => {
|
||||
// Open sidebar on the left and resize
|
||||
await openSidebarAt(comfyPage, 'left')
|
||||
await dragGutter(comfyPage, 50)
|
||||
|
||||
// Left-only sidebar should use the legacy key (no location suffix)
|
||||
const leftKey = await comfyPage.page.evaluate(() =>
|
||||
localStorage.getItem('unified-sidebar')
|
||||
)
|
||||
expect(leftKey).not.toBeNull()
|
||||
|
||||
// Switch to right and resize
|
||||
await comfyPage.menu.nodeLibraryTab.close()
|
||||
await openSidebarAt(comfyPage, 'right')
|
||||
await dragGutter(comfyPage, -50)
|
||||
|
||||
// Right sidebar should use a different key with location suffix
|
||||
const rightKey = await comfyPage.page.evaluate(() =>
|
||||
localStorage.getItem('unified-sidebar-right')
|
||||
)
|
||||
expect(rightKey).not.toBeNull()
|
||||
|
||||
// Both keys should exist independently
|
||||
const leftKeyStillExists = await comfyPage.page.evaluate(() =>
|
||||
localStorage.getItem('unified-sidebar')
|
||||
)
|
||||
expect(leftKeyStillExists).not.toBeNull()
|
||||
})
|
||||
|
||||
test('normalized panel sizes sum to approximately 100%', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openSidebarAt(comfyPage, 'left')
|
||||
await dragGutter(comfyPage, 80)
|
||||
|
||||
// Check that saved sizes sum to ~100%
|
||||
const sizes = await comfyPage.page.evaluate(() => {
|
||||
const raw = localStorage.getItem('unified-sidebar')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
|
||||
expect(sizes).not.toBeNull()
|
||||
expect(Array.isArray(sizes)).toBe(true)
|
||||
|
||||
const sum = (sizes as number[]).reduce((a, b) => a + b, 0)
|
||||
expect(sum).toBeGreaterThan(99)
|
||||
expect(sum).toBeLessThanOrEqual(101)
|
||||
})
|
||||
})
|
||||
@@ -247,7 +247,7 @@ test.describe('Workflows sidebar', () => {
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Load blank workflow
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy prefixed proxyWidget normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const warnings: string[] = []
|
||||
comfyPage.page.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (
|
||||
text.includes('Failed to resolve legacy -1') ||
|
||||
text.includes('No link found') ||
|
||||
text.includes('No inner link found')
|
||||
) {
|
||||
warnings.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('Promoted widget renders with normalized name, not legacy prefix', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The promoted widget should render with the clean name "string_a",
|
||||
// not the legacy-prefixed "6: 3: string_a".
|
||||
const promotedWidget = outerNode
|
||||
.getByLabel('string_a', { exact: true })
|
||||
.first()
|
||||
await expect(promotedWidget).toBeVisible()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// Both widget rows should be valid "string_a" widgets — no stale
|
||||
// "Disconnected" placeholders from unresolved legacy entries.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(row.getByLabel('string_a', { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widget value is editable as a text input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -142,12 +142,12 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
|
||||
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
@@ -182,8 +182,8 @@ test.describe(
|
||||
})
|
||||
})
|
||||
.toEqual({
|
||||
proxyWidgetCount: initialWidgets.length,
|
||||
firstWidgetType: 'button'
|
||||
proxyWidgetCount: 0,
|
||||
firstWidgetType: undefined
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
195
browser_tests/tests/subgraphNestedPackValues.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Regression test for PR #10532:
|
||||
* Packing all nodes inside a subgraph into a nested subgraph was causing
|
||||
* the parent subgraph node's promoted widget values to go blank.
|
||||
*
|
||||
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
|
||||
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
|
||||
* at promotion time). After repointing, input._widget still pointed to
|
||||
* removed node IDs, causing missing-node failures and blank values on the
|
||||
* next checkState cycle.
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph pack preserves promoted widget values',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
|
||||
const HOST_NODE_ID = '57'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// 1. Verify initial promoted widget values via Vue node DOM
|
||||
const widthWidget = nodeLocator
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
const heightWidget = nodeLocator
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsWidget = nodeLocator
|
||||
.getByLabel('steps', { exact: true })
|
||||
.first()
|
||||
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthWidget)
|
||||
const heightControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightWidget)
|
||||
const stepsControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
||||
|
||||
await expect(async () => {
|
||||
await expect(widthControls.input).toHaveValue('1024')
|
||||
await expect(heightControls.input).toHaveValue('1024')
|
||||
await expect(stepsControls.input).toHaveValue('8')
|
||||
await expect(textWidget).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Enter the subgraph via Vue node button
|
||||
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// 3. Disable Vue nodes for canvas operations (select all + convert)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// 4. Select all interior nodes and convert to nested subgraph
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.graph!.convertToSubgraph(canvas.selectedItems)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// 5. Navigate back to root graph and trigger a checkState cycle
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// 6. Re-enable Vue nodes and verify values are preserved
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeAfter).toBeVisible()
|
||||
|
||||
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
|
||||
const heightAfter = nodeAfter
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
|
||||
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthAfter)
|
||||
const heightControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightAfter)
|
||||
const stepsControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
|
||||
|
||||
await expect(async () => {
|
||||
await expect(widthControlsAfter.input).toHaveValue('1024')
|
||||
await expect(heightControlsAfter.input).toHaveValue('1024')
|
||||
await expect(stepsControlsAfter.input).toHaveValue('8')
|
||||
await expect(textAfter).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Verify the host node is visible
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// Enter the subgraph via Vue node button, then disable for canvas ops
|
||||
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.graph!.convertToSubgraph(canvas.selectedItems)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify all proxyWidgets entries resolve
|
||||
await expect(async () => {
|
||||
const result = await comfyPage.page.evaluate((hostId) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
) {
|
||||
return { error: 'Host node not found or not a subgraph node' }
|
||||
}
|
||||
|
||||
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
|
||||
const entries = (proxyWidgets as unknown[])
|
||||
.filter(
|
||||
(e): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string' &&
|
||||
!e[1].startsWith('$$')
|
||||
)
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId))
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return { entries, count: entries.length }
|
||||
}, HOST_NODE_ID)
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
const { entries, count } = result as {
|
||||
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
|
||||
count: number
|
||||
}
|
||||
expect(count).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
entry.resolved,
|
||||
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
51
browser_tests/tests/subgraphNestedStaleProxyWidgets.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
|
||||
|
||||
/**
|
||||
* Regression test for nested subgraph packing leaving stale proxyWidgets
|
||||
* on the outer SubgraphNode.
|
||||
*
|
||||
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
|
||||
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
|
||||
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
|
||||
* Only ["3","seed"] (KSampler) should remain.
|
||||
*
|
||||
* Stale entries render as "Disconnected" placeholder widgets (type "button").
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph stale proxyWidgets',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
// Only the KSampler seed widget should be present — no stale
|
||||
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
|
||||
await expect(widgets).toHaveCount(1)
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
|
||||
// Verify the seed widget is present via its label
|
||||
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
132
browser_tests/tests/subgraphSlotAlignment.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for link misalignment on SubgraphNodes when loading
|
||||
* workflows with workflowRendererVersion: "LG".
|
||||
*
|
||||
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
|
||||
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
|
||||
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
|
||||
* slot offsets. The fix uses DOM-relative measurement instead.
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph slot alignment after LG layout scale',
|
||||
{ tag: ['@subgraph', '@canvas'] },
|
||||
() => {
|
||||
test('slot positions stay within node bounds after loading LG workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const SLOT_BOUNDS_MARGIN = 20
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const workflowPath = resolve(
|
||||
import.meta.dirname,
|
||||
'../assets/subgraphs/basic-subgraph.json'
|
||||
)
|
||||
const workflow = JSON.parse(
|
||||
readFileSync(workflowPath, 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
workflow.extra = {
|
||||
...workflow.extra,
|
||||
workflowRendererVersion: 'LG'
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
(wf) =>
|
||||
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
|
||||
openSource: 'template'
|
||||
}),
|
||||
workflow
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -206,6 +206,31 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
test(
|
||||
'select components in filter bar render correctly',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Wait for filter bar select components to render
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
|
||||
await expect(sortBySelect).toBeVisible()
|
||||
|
||||
// Screenshot the filter bar containing MultiSelect and SingleSelect
|
||||
const filterBar = sortBySelect.locator(
|
||||
'xpath=ancestor::div[contains(@class, "justify-between")]'
|
||||
)
|
||||
await expect(filterBar).toHaveScreenshot(
|
||||
'template-filter-bar-select-components.png',
|
||||
{
|
||||
mask: [comfyPage.page.locator('.p-toast')]
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'template cards descriptions adjust height dynamically',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
@@ -47,6 +47,46 @@ test.describe('Vue Node Moving', () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
|
||||
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
|
||||
|
||||
// The small movement should have selected the node, not dragged it
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('should move node when pointer moves beyond drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move 50px — well beyond the 3px drag threshold
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading image').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading video').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,4 +46,16 @@ test.describe('Vue Multiline String Widget', () => {
|
||||
|
||||
await expect(textarea).toHaveValue('Keep me around')
|
||||
})
|
||||
test('should use native context menu when focused', async ({ comfyPage }) => {
|
||||
const textarea = getFirstMultilineStringWidget(comfyPage)
|
||||
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
|
||||
await textarea.focus()
|
||||
await textarea.click({ button: 'right' })
|
||||
await expect(vueContextMenu).not.toBeVisible()
|
||||
await textarea.blur()
|
||||
|
||||
await textarea.click({ button: 'right' })
|
||||
await expect(vueContextMenu).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
138
browser_tests/tests/zoomControls.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Zoom Controls', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.page.waitForFunction(() => window.app && window.app.canvas)
|
||||
})
|
||||
|
||||
test('Default zoom is 100% and node has a size', async ({ comfyPage }) => {
|
||||
const nodeSize = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.nodes[0].size
|
||||
)
|
||||
expect(nodeSize[0]).toBeGreaterThan(0)
|
||||
expect(nodeSize[1]).toBeGreaterThan(0)
|
||||
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await expect(zoomButton).toContainText('100%')
|
||||
|
||||
const scale = await comfyPage.canvasOps.getScale()
|
||||
expect(scale).toBeCloseTo(1.0, 1)
|
||||
})
|
||||
|
||||
test('Zoom to fit reduces percentage', async ({ comfyPage }) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
|
||||
await expect(zoomToFit).toBeVisible()
|
||||
await zoomToFit.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeLessThan(1.0)
|
||||
|
||||
await expect(zoomButton).not.toContainText('100%')
|
||||
})
|
||||
|
||||
test('Zoom out reduces percentage', async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
await zoomOut.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeLessThan(initialScale)
|
||||
})
|
||||
|
||||
test('Zoom out clamps at 10% minimum', async ({ comfyPage }) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await zoomOut.click()
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeCloseTo(0.1, 1)
|
||||
|
||||
await expect(zoomButton).toContainText('10%')
|
||||
})
|
||||
|
||||
test('Manual percentage entry allows zoom in and zoom out', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const input = comfyPage.page
|
||||
.getByTestId(TestIds.canvas.zoomPercentageInput)
|
||||
.locator('input')
|
||||
await input.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await input.pressSequentially('100')
|
||||
await input.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeCloseTo(1.0, 1)
|
||||
|
||||
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)
|
||||
await zoomIn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfterZoomIn = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfterZoomIn).toBeGreaterThan(1.0)
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
await zoomOut.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfterZoomOut = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfterZoomOut).toBeLessThan(scaleAfterZoomIn)
|
||||
})
|
||||
|
||||
test('Clicking zoom button toggles zoom controls visibility', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
|
||||
await expect(zoomToFit).toBeVisible()
|
||||
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(zoomToFit).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
239
docs/adr/0008-entity-component-system.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# 8. Entity Component System
|
||||
|
||||
Date: 2026-03-23
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
|
||||
|
||||
This coupling makes it difficult to:
|
||||
|
||||
- Add cross-cutting concerns (undo/redo, serialization, multiplayer CRDT sync, rendering optimization) without modifying every class
|
||||
- Test individual aspects of an entity in isolation
|
||||
- Evolve rendering, serialization, and execution logic independently
|
||||
- Implement the CRDT-based layout system proposed in [ADR 0003](0003-crdt-based-layout-system.md)
|
||||
|
||||
An Entity Component System (ECS) separates **identity** (entities), **data** (components), and **behavior** (systems), enabling each concern to evolve independently.
|
||||
|
||||
### Current pain points
|
||||
|
||||
- **God objects**: `LGraphNode` (~2000+ lines) mixes position, rendering, connectivity, execution, serialization, and input handling
|
||||
- **Circular dependencies**: `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, requiring careful import ordering and barrel exports
|
||||
- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
|
||||
- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.
|
||||
|
||||
### Entity Taxonomy
|
||||
|
||||
Six entity kinds, each with a branded ID type:
|
||||
|
||||
| Entity Kind | Current Class(es) | Current ID | Branded ID |
|
||||
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
|
||||
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
|
||||
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
|
||||
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
|
||||
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
|
||||
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
|
||||
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
|
||||
|
||||
Subgraphs are not a separate entity kind. A subgraph is a node with a `SubgraphStructure` component. See [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) for the full design rationale.
|
||||
|
||||
### Branded ID Design
|
||||
|
||||
Each entity kind gets a nominal/branded type wrapping its underlying primitive. The brand prevents accidental cross-kind usage at compile time while remaining structurally compatible with existing ID types:
|
||||
|
||||
```ts
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
|
||||
// Scope identifier, not an entity ID
|
||||
type GraphId = string & { readonly __brand: 'GraphId' }
|
||||
```
|
||||
|
||||
Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
|
||||
|
||||
### Component Decomposition
|
||||
|
||||
Components are plain data objects — no methods, no back-references to parent entities. Systems query components to implement behavior.
|
||||
|
||||
#### Shared Components
|
||||
|
||||
- **Position** — `{ pos: Point }` — used by Node, Reroute, Group
|
||||
- **Dimensions** — `{ size: Size, bounding: Rectangle }` — used by Node, Group
|
||||
- **Visual** — rendering properties specific to each entity kind (separate interfaces, shared naming convention)
|
||||
|
||||
#### Node
|
||||
|
||||
| Component | Data (from `LGraphNode`) |
|
||||
| ----------------- | --------------------------------------------------- |
|
||||
| `Position` | `pos` |
|
||||
| `Dimensions` | `size`, `_bounding` |
|
||||
| `NodeVisual` | `color`, `bgcolor`, `boxcolor`, `title` |
|
||||
| `NodeType` | `type`, `category`, `nodeData`, `description` |
|
||||
| `Connectivity` | slot entity refs (replaces `inputs[]`, `outputs[]`) |
|
||||
| `Execution` | `order`, `mode`, `flags` |
|
||||
| `Properties` | `properties`, `properties_info` |
|
||||
| `WidgetContainer` | widget entity refs (replaces `widgets[]`) |
|
||||
|
||||
#### Link
|
||||
|
||||
| Component | Data (from `LLink`) |
|
||||
| --------------- | -------------------------------------------------------------- |
|
||||
| `LinkEndpoints` | `origin_id`, `origin_slot`, `target_id`, `target_slot`, `type` |
|
||||
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
|
||||
| `LinkState` | `_dragging`, `data` |
|
||||
|
||||
#### Subgraph (Node Components)
|
||||
|
||||
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
||||
|
||||
| Component | Data |
|
||||
| ------------------- | ------------------------------------------------------------------------ |
|
||||
| `SubgraphStructure` | `graphId`, typed interface (input/output names, types, slot entity refs) |
|
||||
| `SubgraphMeta` | `name`, `description` |
|
||||
|
||||
#### Widget
|
||||
|
||||
| Component | Data (from `BaseWidget`) |
|
||||
| ---------------- | ----------------------------------------------------------- |
|
||||
| `WidgetIdentity` | `name`, `type` (widget type string), parent node entity ref |
|
||||
| `WidgetValue` | `value`, `options`, `serialize` flags |
|
||||
| `WidgetLayout` | `computedHeight`, layout size constraints |
|
||||
|
||||
#### Slot
|
||||
|
||||
| Component | Data (from `SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) |
|
||||
| ---------------- | ----------------------------------------------------------------------------------- |
|
||||
| `SlotIdentity` | `name`, `type` (slot type), direction (`input` or `output`), parent node ref, index |
|
||||
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
|
||||
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
|
||||
|
||||
#### Reroute
|
||||
|
||||
| Component | Data (from `Reroute`) |
|
||||
| --------------- | --------------------------------- |
|
||||
| `Position` | `pos` (shared) |
|
||||
| `RerouteLinks` | `parentId`, input/output link IDs |
|
||||
| `RerouteVisual` | `color`, badge config |
|
||||
|
||||
#### Group
|
||||
|
||||
| Component | Data (from `LGraphGroup`) |
|
||||
| --------------- | ----------------------------------- |
|
||||
| `Position` | `pos` (shared) |
|
||||
| `Dimensions` | `size`, `bounding` |
|
||||
| `GroupMeta` | `title`, `font`, `font_size` |
|
||||
| `GroupVisual` | `color` |
|
||||
| `GroupChildren` | child entity refs (nodes, reroutes) |
|
||||
|
||||
### World
|
||||
|
||||
A central registry (the "World") maps entity IDs to their component sets. One
|
||||
World exists per workflow instance, containing all entities across all nesting
|
||||
levels. Each entity carries a `graphScope` identifier linking it to its
|
||||
containing graph. The World also maintains a scope registry mapping each
|
||||
`graphId` to its parent (or null for the root graph).
|
||||
|
||||
The "single source of truth" claim in this ADR is scoped to one workflow
|
||||
instance. In a future linked-subgraph model, shared definitions can be loaded
|
||||
into multiple workflow instances, but mutable runtime components
|
||||
(`WidgetValue`, execution state, selection, transient layout caches) remain
|
||||
instance-scoped unless explicitly declared shareable.
|
||||
|
||||
### Subgraph recursion model
|
||||
|
||||
The ECS model preserves recursive nesting without inheritance. A subgraph node
|
||||
stores `SubgraphStructure.childGraphId`, and the scope registry stores
|
||||
`childGraphId -> parentGraphId`. This forms a DAG that can represent arbitrary
|
||||
subgraph depth.
|
||||
|
||||
Queries such as "all nodes at depth N" run by traversing the scope registry
|
||||
from the root, materializing graph IDs at depth `N`, and then filtering entity
|
||||
queries by `graphScope`.
|
||||
|
||||
### Systems (future work)
|
||||
|
||||
Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:
|
||||
|
||||
- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components
|
||||
- **SerializationSystem** — queries all components to produce/consume workflow JSON
|
||||
- **ExecutionSystem** — queries `Execution` + `Connectivity` to determine run order
|
||||
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
|
||||
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
|
||||
|
||||
System design is deferred to a future ADR.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Define types** — branded IDs, component interfaces, World type in a new `src/ecs/` directory
|
||||
2. **Bridge layer** — adapter functions that read ECS components from existing class instances (zero-copy where possible)
|
||||
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties
|
||||
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
|
||||
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
|
||||
|
||||
### Relationship to ADR 0003 (Command Pattern / CRDT)
|
||||
|
||||
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
|
||||
|
||||
- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
|
||||
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World.
|
||||
- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands.
|
||||
|
||||
The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.
|
||||
|
||||
For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md).
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
- **Refactoring classes in place**: Lower initial cost, but doesn't solve the cross-cutting concern problem. Each new feature still requires modifying multiple god objects.
|
||||
- **Full rewrite**: Higher risk, blocks feature work during migration. The incremental approach avoids this.
|
||||
- **Using an existing ECS library** (e.g., bitecs, miniplex): Adds a dependency for a domain that is specific to this project. The graph domain's component shapes don't align well with the dense numeric arrays favored by game-oriented ECS libraries. A lightweight, purpose-built approach is preferred.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
|
||||
- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic
|
||||
- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
|
||||
- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
|
||||
- Aligns with the CRDT layout system direction from ADR 0003
|
||||
|
||||
### Negative
|
||||
|
||||
- Additional indirection: reading a node's position requires a World lookup instead of `node.pos`
|
||||
- Learning curve for contributors unfamiliar with ECS patterns
|
||||
- Migration period where both OOP and ECS patterns coexist, increasing cognitive load
|
||||
- Widgets and Slots need synthetic IDs, adding ID management complexity
|
||||
|
||||
### Render-Loop Performance Implications and Mitigations
|
||||
|
||||
Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
|
||||
|
||||
Planned mitigations for the ECS render path:
|
||||
|
||||
1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
|
||||
2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities.
|
||||
3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
|
||||
4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
|
||||
5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).
|
||||
|
||||
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
|
||||
|
||||
## Notes
|
||||
|
||||
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
|
||||
- Subgraphs are not a separate entity kind. A `GraphId` scope identifier (branded `string`) tracks which graph an entity belongs to. The scope DAG must be acyclic — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
||||
- The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters extend naturally to `lastWidgetId` and `lastSlotId`.
|
||||
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
|
||||
@@ -17,6 +17,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
138
docs/architecture/appendix-critical-analysis.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Appendix: A Critical Analysis of the Architecture Documents
|
||||
|
||||
_In which we examine the shadow material of a codebase in individuation, verify its self-reported symptoms, and note where the ego's aspirations outpace the psyche's readiness for transformation._
|
||||
|
||||
---
|
||||
|
||||
## I. On the Accuracy of Self-Diagnosis
|
||||
|
||||
Verification snapshot: code references were checked against commit
|
||||
`e51982ee1`.
|
||||
|
||||
The architecture documents present themselves as a clinical intake — a patient arriving with a detailed account of its own suffering. One is naturally suspicious of such thoroughness; the neurotic who describes his symptoms too precisely is often defending against a deeper, unnamed wound. And yet, upon examination, we find the self-report to be remarkably honest.
|
||||
|
||||
The god-objects are as large as claimed. `LGraphCanvas` contains 9,094 lines — the ego of the system, attempting to mediate between the inner world of data and the outer world of the user, and collapsing under the weight of that mediation. `LGraphNode` at 4,285 lines and `LGraph` at 3,114 confirm that these are not exaggerations born of self-pity but accurate measurements of genuine hypertrophy.
|
||||
|
||||
Some thirty specific line references were verified against the living code. The `renderingColor` getter sits precisely at line 328. The `drawNode()` method begins exactly at line 5554, and within it, at lines 5562 and 5564, the render pass mutates state — `_setConcreteSlots()` and `arrange()` — just as the documents confess. The scattered `_version++` increments appear at every claimed location across all three files. The module-scope store invocations in `LLink.ts:24` and `Reroute.ts:23` are exactly where indicated.
|
||||
|
||||
The stores — all six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects. The `PromotionStore` does maintain its ref-counted maps. The `LayoutStore` does wrap Y.js CRDTs.
|
||||
|
||||
This level of factual accuracy — 28 out of 30 sampled citation checks
|
||||
(93.3%) — is, one might say, the work of a consciousness that has genuinely
|
||||
confronted its shadow material rather than merely projecting it.
|
||||
|
||||
## II. On the Errors: Small Falsifications of Memory
|
||||
|
||||
No self-report is without its distortions. The unconscious edits memory, not out of malice, but because the psyche organizes experience around meaning rather than fact.
|
||||
|
||||
Five such distortions were identified:
|
||||
|
||||
**The Misnamed Method.** The documents claim `toJSON()` exists at `LGraphNode.ts:1033`. In truth, line 1033 holds `toString()`. This is a telling substitution — the psyche conflates the act of converting oneself to a string representation (how one _appears_) with the act of serializing oneself for transmission (how one _persists_). These are different operations, but the patient experiences them as the same anxiety.
|
||||
|
||||
**The Renamed Function.** `execute()` is cited at line 1418. The actual method is `doExecute()` at line 1411. The prefix "do" carries weight — it is the difference between the intention and the act, between the persona and the behavior. The documents elide this distinction, preferring the cleaner, more archetypal name.
|
||||
|
||||
**The Understated Magnitude.** The documents claim `LGraphNode` has ~539 method/property definitions. A systematic count yields approximately 848. The psyche has minimized the extent of the fragmentation — a common defense. One does not wish to know the full measure of one's own complexity.
|
||||
|
||||
**The Compressed History.** `LGraph.configure()` is described as ~180 lines. It spans approximately 247. The method has grown since it was last measured, as living things do, but the documents preserve an earlier, smaller self-image. Time has passed; the patient has not updated its intake form.
|
||||
|
||||
**The Phantom Method.** The proto-ECS analysis references `resolveDeepest()` on the `PromotedWidgetViewManager`. This method does not exist. The class uses `reconcile()` and `getOrCreate()` — less evocative names for what is, symbolically, the same operation: reaching through layers of abstraction to find the authentic, concrete thing beneath. The documents have invented a name that better captures the _meaning_ of the operation than the names the code actually uses. This is poetry, not documentation.
|
||||
|
||||
These errors are minor in isolation. Collectively, they suggest a pattern familiar to the analyst: the documents describe the system not quite as it _is_, but as it _understands itself to be_. The gap between these is small — but it is precisely in such gaps that the interesting material lives.
|
||||
|
||||
## III. On the Dream of the World: The ECS Target as Individuation Fantasy
|
||||
|
||||
The target architecture documents read as a vision of wholeness. Where the current system is fragmented — god-objects carrying too many responsibilities, circular dependencies binding parent to child in mutual entanglement, scattered side effects erupting unpredictably — the ECS future promises integration. A single World. Pure systems. Branded identities. Unidirectional flow.
|
||||
|
||||
This is the individuation dream: the fragmented psyche imagines itself unified, each complex (component) named and contained, each archetypal function (system) operating in its proper domain, the Self (World) holding all of it in coherent relation.
|
||||
|
||||
It is a beautiful vision. It is also, in several respects, a fantasy that has not yet been tested against reality.
|
||||
|
||||
### The Line-Count Comparisons
|
||||
|
||||
The lifecycle scenarios compare current implementations against projected ECS equivalents:
|
||||
|
||||
| Operation | Current | Projected ECS |
|
||||
| ------------- | ---------- | ------------- |
|
||||
| Node removal | ~107 lines | ~30 lines |
|
||||
| Pack subgraph | ~200 lines | ~50 lines |
|
||||
| Copy/paste | ~300 lines | ~60 lines |
|
||||
|
||||
These ratios — roughly 4:1 — are the ratios of a daydream. They may prove accurate. But they are estimates for code that does not yet exist, and the unconscious is generous with its projections of future ease. Real implementations accumulate weight as they encounter the particularities that theory elides: validation callbacks, error recovery, extension hooks, the sheer cussedness of edge cases that only reveal themselves in production.
|
||||
|
||||
The documents would benefit from acknowledging this uncertainty explicitly. "We expect" is more honest than "it will be."
|
||||
|
||||
### The Vanishing Callbacks
|
||||
|
||||
The current system maintains an elaborate network of lifecycle callbacks: `onConnectInput()`, `onConnectOutput()`, `onConnectionsChange()`, `onRemoved()`, `onAdded()`. These are the system's relationships — its contracts with the extensions that depend upon it.
|
||||
|
||||
The ECS scenarios show these callbacks disappearing. "No callbacks — systems query World after deserialization." This is presented as simplification, and structurally it is. But psychologically, it is the most dangerous moment in any transformation: the point at which the individuating self believes it can shed its relationships without consequence.
|
||||
|
||||
Extensions rely on these callbacks. They are the public API through which the outer world interacts with the system's inner life. The documents do not discuss how this API would be preserved, adapted, or replaced. This is not a minor omission — it is the repression of the system's most anxiety-producing constraint.
|
||||
|
||||
### The Atomicity Wish
|
||||
|
||||
The ECS scenarios describe operations as "atomic" — pack subgraph, unpack subgraph, node removal, all happening as unified state transitions with no intermediate inconsistency.
|
||||
|
||||
This is the wish for a moment of transformation without vulnerability. In reality, unless the World implements transactional semantics, a failure mid-operation would leave the same inconsistent state the current system risks. The existing `beforeChange()`/`afterChange()` pattern, for all its scattered invocations, at least provides undo snapshots. The documents do not discuss what replaces this guarantee.
|
||||
|
||||
The desire for atomicity is healthy. The assumption that it comes free with the architecture is not.
|
||||
|
||||
### The CRDT Question
|
||||
|
||||
The `LayoutStore` is correctly identified as "the most architecturally advanced extraction." It wraps Y.js CRDTs — a technology chosen for collaborative editing, as noted in ADR 0003.
|
||||
|
||||
But the documents do not address the tension between Y.js and a pure ECS World. Would the World contain Y.js documents? Would it replace them? Would the Position component be a CRDT, a plain object, or a proxy that reads from one? This is not an implementation detail — it is a fundamental architectural question about whether the system's two most sophisticated subsystems (collaboration and ECS) can coexist or must be reconciled.
|
||||
|
||||
The silence on this point is the silence of a psyche that has not yet confronted a genuine dilemma.
|
||||
|
||||
## IV. On the Keying Strategies: Identity and Its Discontents
|
||||
|
||||
The proto-ECS analysis catalogs five different keying strategies across five stores and presents this multiplicity as pathological. There is truth in this — the absence of a unified identity system does create real confusion and real bugs.
|
||||
|
||||
But one must be careful not to mistake diversity for disorder. Some of these composite keys — `"${nodeId}:${widgetName}"`, for instance — reflect a genuine structural reality: a widget is identified by its relationship to a node and its name within that node. A branded `WidgetEntityId` would replace this composite key with a synthetic integer, gaining cross-kind safety but losing the self-documenting quality of the composite.
|
||||
|
||||
The documents present branded IDs as an unqualified improvement. They are an improvement in _type safety_. Whether they are an improvement in _comprehensibility_ depends on whether the system provides good lookup APIs. The analysis would benefit from acknowledging this tradeoff rather than presenting it as a pure gain.
|
||||
|
||||
## V. On the Subgraph: The Child Who Contains the Parent
|
||||
|
||||
The documents describe the `Subgraph extends LGraph` relationship as a circular dependency. This is technically accurate and architecturally concerning. But it is also, symbolically, the most interesting structure in the entire system.
|
||||
|
||||
A Subgraph is a Graph that lives inside a Node that lives inside a Graph. It is the child that contains the parent's structure — the recursive self-reference that gives the system its power and its pathology simultaneously. The barrel export comment at `litegraph.ts:15` is a symptom, yes, but it is also an honest acknowledgment of a genuine structural paradox.
|
||||
|
||||
The ECS target resolves this by flattening: "Entities are just IDs. No inheritance hierarchy." This is a valid architectural choice. But it is worth noting that the current circular structure _accurately models the domain_. A subgraph _is_ a graph. The inheritance relationship is not arbitrary — it reflects a real isomorphism.
|
||||
|
||||
The ECS approach replaces structural modeling with data modeling. This eliminates the circular dependency but requires the system to reconstruct the "a subgraph is a graph" relationship through component composition rather than inheritance. The documents assume this is straightforward. It may not be — the recursive case (subgraphs containing subgraphs) will test whether flat entity composition can express what hierarchical inheritance expresses naturally.
|
||||
|
||||
## VI. On the Migration Bridge: The Transitional Object
|
||||
|
||||
The migration bridge described in the target architecture is perhaps the most psychologically astute element of the entire proposal. It acknowledges that transformation cannot happen all at once — that the old structures must coexist with the new until the new have proven themselves capable of bearing the load.
|
||||
|
||||
The three-phase sequence (bridge reads from class and writes to World; new features build on World directly; bridge removed) is the sequence of every successful therapeutic process: first, the new understanding runs alongside the old patterns; then, new behavior begins to emerge from the new understanding; finally, the old patterns are released because they are no longer needed, not because they have been forcibly suppressed.
|
||||
|
||||
This is sound. The documents would benefit from being equally realistic about the _duration_ of the bridge phase. In a system with this many extensions, this much surface area, and this much organic complexity, the bridge may persist for a very long time. This is not failure — it is the natural pace of genuine transformation.
|
||||
|
||||
## VII. Summary of Findings
|
||||
|
||||
### Factual Corrections Required
|
||||
|
||||
| Document | Error | Correction |
|
||||
| --------------------- | ---------------------------------- | ---------------------------------- |
|
||||
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
|
||||
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
|
||||
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
|
||||
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
|
||||
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | `reconcile()` / `getOrCreate()` |
|
||||
|
||||
### Analytical Gaps
|
||||
|
||||
1. **Extension API continuity** is the largest unaddressed risk in the migration.
|
||||
2. **Atomicity guarantees** are claimed but not mechanically specified.
|
||||
3. **Y.js / ECS coexistence** is an open architectural question the documents do not engage.
|
||||
4. **ECS line-count projections** are aspirational and should be marked as estimates.
|
||||
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything."
|
||||
|
||||
### What the Documents Do Well
|
||||
|
||||
The problem diagnosis is grounded, specific, and verified. The proto-ECS analysis correctly identifies organic convergence toward ECS patterns. The lifecycle scenarios effectively communicate the structural simplification that ECS enables. The change-tracker document is accurate and immediately useful.
|
||||
|
||||
These are the documents of a system that has looked at itself honestly — which is, as any analyst will tell you, the necessary precondition for change.
|
||||
744
docs/architecture/ecs-lifecycle-scenarios.md
Normal file
@@ -0,0 +1,744 @@
|
||||
# ECS Lifecycle Scenarios
|
||||
|
||||
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (what it looks like with the World), and a **Key Differences** table.
|
||||
|
||||
## 1. Node Removal
|
||||
|
||||
### Current Flow
|
||||
|
||||
`LGraph.remove(node)` — 107 lines, touches 6+ entity types and 4+ external systems:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant G as LGraph
|
||||
participant N as LGraphNode
|
||||
participant L as LLink
|
||||
participant R as Reroute
|
||||
participant C as LGraphCanvas
|
||||
participant LS as LayoutStore
|
||||
|
||||
Caller->>G: remove(node)
|
||||
G->>G: beforeChange() [undo checkpoint]
|
||||
|
||||
loop each input slot
|
||||
G->>N: disconnectInput(i)
|
||||
N->>L: link.disconnect(network)
|
||||
L->>G: _links.delete(linkId)
|
||||
L->>R: cleanup orphaned reroutes
|
||||
N->>LS: layoutMutations.removeLink()
|
||||
N->>G: _version++
|
||||
end
|
||||
|
||||
loop each output slot
|
||||
G->>N: disconnectOutput(i)
|
||||
Note over N,R: same cascade as above
|
||||
end
|
||||
|
||||
G->>G: scan floatingLinks for node refs
|
||||
G->>G: if SubgraphNode: check refs, maybe delete subgraph def
|
||||
G->>N: node.onRemoved?.()
|
||||
G->>N: node.graph = null
|
||||
G->>G: _version++
|
||||
|
||||
loop each canvas
|
||||
G->>C: deselect(node)
|
||||
G->>C: delete selected_nodes[id]
|
||||
end
|
||||
|
||||
G->>G: splice from _nodes[], delete from _nodes_by_id
|
||||
G->>G: onNodeRemoved?.(node)
|
||||
G->>C: setDirtyCanvas(true, true)
|
||||
G->>G: afterChange() [undo checkpoint]
|
||||
G->>G: updateExecutionOrder()
|
||||
```
|
||||
|
||||
Problems: the graph method manually disconnects every slot, cleans up reroutes, scans floating links, checks subgraph references, notifies canvases, and recomputes execution order — all in one method that knows about every entity type.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant VS as VersionSystem
|
||||
|
||||
Caller->>CS: removeNode(world, nodeId)
|
||||
|
||||
CS->>W: getComponent(nodeId, Connectivity)
|
||||
W-->>CS: { inputSlotIds, outputSlotIds }
|
||||
|
||||
loop each slotId
|
||||
CS->>W: getComponent(slotId, SlotConnection)
|
||||
W-->>CS: { linkIds }
|
||||
loop each linkId
|
||||
CS->>CS: removeLink(world, linkId)
|
||||
Note over CS,W: removes Link entity + updates remote slots
|
||||
end
|
||||
CS->>W: deleteEntity(slotId)
|
||||
end
|
||||
|
||||
CS->>W: getComponent(nodeId, WidgetContainer)
|
||||
W-->>CS: { widgetIds }
|
||||
loop each widgetId
|
||||
CS->>W: deleteEntity(widgetId)
|
||||
end
|
||||
|
||||
CS->>W: deleteEntity(nodeId)
|
||||
Note over W: removes Position, NodeVisual, NodeType,<br/>Connectivity, Execution, Properties,<br/>WidgetContainer — all at once
|
||||
|
||||
CS->>VS: markChanged()
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------- | ------------------------------------------------ | ------------------------------------------------------ |
|
||||
| Lines of code | ~107 in one method | ~30 in system function |
|
||||
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem knows Connectivity + SlotConnection |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteEntity()` removes all components atomically |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | RenderSystem sees missing entity on next frame |
|
||||
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | World deletion IS the cleanup |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | System snapshots affected components before deletion |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only World + ConnectivitySystem |
|
||||
|
||||
## 2. Serialization
|
||||
|
||||
### Current Flow
|
||||
|
||||
`LGraph.serialize()` → `asSerialisable()` — walks every collection manually:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant G as LGraph
|
||||
participant N as LGraphNode
|
||||
participant L as LLink
|
||||
participant R as Reroute
|
||||
participant Gr as LGraphGroup
|
||||
participant SG as Subgraph
|
||||
|
||||
Caller->>G: serialize()
|
||||
G->>G: asSerialisable()
|
||||
|
||||
loop each node
|
||||
G->>N: node.serialize()
|
||||
N->>N: snapshot inputs, outputs (with link IDs)
|
||||
N->>N: snapshot properties
|
||||
N->>N: collect widgets_values[]
|
||||
N-->>G: ISerialisedNode
|
||||
end
|
||||
|
||||
loop each link
|
||||
G->>L: link.asSerialisable()
|
||||
L-->>G: SerialisableLLink
|
||||
end
|
||||
|
||||
loop each reroute
|
||||
G->>R: reroute.asSerialisable()
|
||||
R-->>G: SerialisableReroute
|
||||
end
|
||||
|
||||
loop each group
|
||||
G->>Gr: group.serialize()
|
||||
Gr-->>G: ISerialisedGroup
|
||||
end
|
||||
|
||||
G->>G: findUsedSubgraphIds()
|
||||
loop each used subgraph
|
||||
G->>SG: subgraph.asSerialisable()
|
||||
Note over SG: recursively serializes internal nodes, links, etc.
|
||||
SG-->>G: ExportedSubgraph
|
||||
end
|
||||
|
||||
G-->>Caller: ISerialisedGraph
|
||||
```
|
||||
|
||||
Problems: serialization logic lives in 6 different `serialize()` methods across 6 classes. Widget values are collected inline during node serialization. The graph walks its own collections — no separation of "what to serialize" from "how to serialize."
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant SS as SerializationSystem
|
||||
participant W as World
|
||||
|
||||
Caller->>SS: serialize(world)
|
||||
|
||||
SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity)
|
||||
W-->>SS: all node entities with their components
|
||||
|
||||
SS->>W: queryAll(LinkEndpoints)
|
||||
W-->>SS: all link entities
|
||||
|
||||
SS->>W: queryAll(SlotIdentity, SlotConnection)
|
||||
W-->>SS: all slot entities
|
||||
|
||||
SS->>W: queryAll(RerouteLinks, Position)
|
||||
W-->>SS: all reroute entities
|
||||
|
||||
SS->>W: queryAll(GroupMeta, GroupChildren, Position)
|
||||
W-->>SS: all group entities
|
||||
|
||||
SS->>W: queryAll(SubgraphStructure, SubgraphMeta)
|
||||
W-->>SS: all subgraph entities
|
||||
|
||||
SS->>SS: assemble JSON from component data
|
||||
SS-->>Caller: SerializedGraph
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------------- | ----------------------------------------------- | ---------------------------------------------- |
|
||||
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem |
|
||||
| Widget values | Collected inline during `node.serialize()` | WidgetValue component queried directly |
|
||||
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat query — SubgraphStructure has entity refs |
|
||||
| Adding a new component | Modify the entity's `serialize()` method | Add component to query in SerializationSystem |
|
||||
| Testing | Need full object graph to test serialization | Mock World with test components |
|
||||
|
||||
## 3. Deserialization
|
||||
|
||||
### Current Flow
|
||||
|
||||
`LGraph.configure(data)` — ~180 lines, two-phase node creation:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant G as LGraph
|
||||
participant N as LGraphNode
|
||||
participant L as LLink
|
||||
participant WVS as WidgetValueStore
|
||||
|
||||
Caller->>G: configure(data)
|
||||
G->>G: clear() [destroy all existing entities]
|
||||
G->>G: _configureBase(data) [set id, extra]
|
||||
|
||||
loop each serialized link
|
||||
G->>L: LLink.create(linkData)
|
||||
G->>G: _links.set(link.id, link)
|
||||
end
|
||||
|
||||
loop each serialized reroute
|
||||
G->>G: setReroute(rerouteData)
|
||||
end
|
||||
|
||||
opt has subgraph definitions
|
||||
G->>G: deduplicateSubgraphNodeIds()
|
||||
loop each subgraph (topological order)
|
||||
G->>G: createSubgraph(data)
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(60, 40, 40)
|
||||
Note over G,N: Phase 1: Create nodes (unlinked)
|
||||
loop each serialized node
|
||||
G->>N: LiteGraph.createNode(type)
|
||||
G->>G: graph.add(node) [assigns ID]
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(40, 60, 40)
|
||||
Note over G,N: Phase 2: Configure nodes (links now resolvable)
|
||||
loop each node
|
||||
G->>N: node.configure(nodeData)
|
||||
N->>N: create slots, restore properties
|
||||
N->>N: resolve links from graph._links
|
||||
N->>N: restore widget values
|
||||
N->>WVS: widget.setNodeId() → register in store
|
||||
N->>N: fire onConnectionsChange per linked slot
|
||||
end
|
||||
end
|
||||
|
||||
G->>G: add floating links
|
||||
G->>G: validate reroutes
|
||||
G->>G: _removeDuplicateLinks()
|
||||
|
||||
loop each serialized group
|
||||
G->>G: create + configure group
|
||||
end
|
||||
|
||||
G->>G: updateExecutionOrder()
|
||||
```
|
||||
|
||||
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant SS as SerializationSystem
|
||||
participant W as World
|
||||
participant LS as LayoutSystem
|
||||
participant ES as ExecutionSystem
|
||||
|
||||
Caller->>SS: deserialize(world, data)
|
||||
|
||||
SS->>W: clear() [remove all entities]
|
||||
|
||||
Note over SS,W: All entities created in one pass — no two-phase needed
|
||||
|
||||
loop each node in data
|
||||
SS->>W: createEntity(NodeEntityId)
|
||||
SS->>W: setComponent(id, Position, {...})
|
||||
SS->>W: setComponent(id, NodeType, {...})
|
||||
SS->>W: setComponent(id, NodeVisual, {...})
|
||||
SS->>W: setComponent(id, Properties, {...})
|
||||
SS->>W: setComponent(id, Execution, {...})
|
||||
end
|
||||
|
||||
loop each slot in data
|
||||
SS->>W: createEntity(SlotEntityId)
|
||||
SS->>W: setComponent(id, SlotIdentity, {...})
|
||||
SS->>W: setComponent(id, SlotConnection, {...})
|
||||
end
|
||||
|
||||
Note over SS,W: Slots reference links by ID — no resolution needed yet
|
||||
|
||||
loop each link in data
|
||||
SS->>W: createEntity(LinkEntityId)
|
||||
SS->>W: setComponent(id, LinkEndpoints, {...})
|
||||
end
|
||||
|
||||
Note over SS,W: Connectivity assembled from slot/link components
|
||||
|
||||
loop each widget in data
|
||||
SS->>W: createEntity(WidgetEntityId)
|
||||
SS->>W: setComponent(id, WidgetIdentity, {...})
|
||||
SS->>W: setComponent(id, WidgetValue, {...})
|
||||
end
|
||||
|
||||
SS->>SS: create reroutes, groups, subgraphs similarly
|
||||
|
||||
Note over SS,W: Systems react to populated World
|
||||
|
||||
SS->>LS: runLayout(world)
|
||||
SS->>ES: computeExecutionOrder(world)
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — components reference IDs, not instances |
|
||||
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: WidgetValue component written directly |
|
||||
| Store population | Side effect of `widget.setNodeId()` | World IS the store — writing component IS population |
|
||||
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems query World after deserialization |
|
||||
| Subgraph ordering | Topological sort required | Flat write — SubgraphStructure just holds entity IDs |
|
||||
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entity → skip; components that loaded are still valid |
|
||||
|
||||
## 4. Pack Subgraph
|
||||
|
||||
### Current Flow
|
||||
|
||||
`LGraph.convertToSubgraph(items)` — clones nodes, computes boundaries, creates SubgraphNode:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant G as LGraph
|
||||
participant N as LGraphNode
|
||||
participant SG as Subgraph
|
||||
participant SGN as SubgraphNode
|
||||
|
||||
Caller->>G: convertToSubgraph(selectedItems)
|
||||
G->>G: beforeChange()
|
||||
|
||||
G->>G: getBoundaryLinks(items)
|
||||
Note over G: classify links as internal, boundary-in, boundary-out
|
||||
|
||||
G->>G: splitPositionables(items) → nodes, reroutes, groups
|
||||
G->>N: multiClone(nodes) → cloned nodes (no links)
|
||||
G->>G: serialize internal links, reroutes
|
||||
|
||||
G->>G: mapSubgraphInputsAndLinks(boundaryInputLinks)
|
||||
G->>G: mapSubgraphOutputsAndLinks(boundaryOutputLinks)
|
||||
|
||||
G->>G: createSubgraph(exportedData)
|
||||
G->>SG: subgraph.configure(data)
|
||||
|
||||
loop disconnect boundary links
|
||||
G->>N: inputNode.disconnectInput()
|
||||
G->>N: outputNode.disconnectOutput()
|
||||
end
|
||||
|
||||
loop remove original items
|
||||
G->>G: remove(node), remove(reroute), remove(group)
|
||||
end
|
||||
|
||||
G->>SGN: LiteGraph.createNode(subgraph.id)
|
||||
G->>G: graph.add(subgraphNode)
|
||||
|
||||
loop reconnect boundary inputs
|
||||
G->>N: externalNode.connectSlots(output, subgraphNode, input)
|
||||
end
|
||||
|
||||
loop reconnect boundary outputs
|
||||
G->>SGN: subgraphNode.connectSlots(output, externalNode, input)
|
||||
end
|
||||
|
||||
G->>G: afterChange()
|
||||
```
|
||||
|
||||
Problems: 200+ lines in one method. Manual boundary link analysis. Clone-serialize-configure dance. Disconnect-remove-recreate-reconnect sequence with many intermediate states where the graph is inconsistent.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
|
||||
Caller->>CS: packSubgraph(world, selectedEntityIds)
|
||||
|
||||
CS->>W: query Connectivity + SlotConnection for selected nodes
|
||||
CS->>CS: classify links as internal vs boundary
|
||||
|
||||
CS->>W: create new GraphId scope in scopes registry
|
||||
|
||||
Note over CS,W: Create SubgraphNode entity in parent scope
|
||||
|
||||
CS->>W: createEntity(NodeEntityId) [the SubgraphNode]
|
||||
CS->>W: setComponent(nodeId, Position, { center of selection })
|
||||
CS->>W: setComponent(nodeId, SubgraphStructure, { graphId, interface })
|
||||
CS->>W: setComponent(nodeId, SubgraphMeta, { name: 'New Subgraph' })
|
||||
|
||||
Note over CS,W: Re-parent selected entities into new graph scope
|
||||
|
||||
loop each selected entity
|
||||
CS->>W: update graphScope to new graphId
|
||||
end
|
||||
|
||||
Note over CS,W: Create boundary slots on SubgraphNode
|
||||
|
||||
loop each boundary input link
|
||||
CS->>W: create SlotEntity on SubgraphNode
|
||||
CS->>W: update LinkEndpoints to target new slot
|
||||
end
|
||||
|
||||
loop each boundary output link
|
||||
CS->>W: create SlotEntity on SubgraphNode
|
||||
CS->>W: update LinkEndpoints to source from new slot
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Entity movement | Clone → serialize → configure → remove originals | Re-parent entities: update graphScope to new GraphId |
|
||||
| Boundary links | Disconnect → remove → recreate → reconnect | Update LinkEndpoints to point at new SubgraphNode slots |
|
||||
| Intermediate inconsistency | Graph is partially disconnected during operation | Atomic: all component writes happen together |
|
||||
| Code size | 200+ lines | ~50 lines in system |
|
||||
| Undo | `beforeChange()`/`afterChange()` wraps everything | Snapshot affected components before mutation |
|
||||
|
||||
## 5. Unpack Subgraph
|
||||
|
||||
### Current Flow
|
||||
|
||||
`LGraph.unpackSubgraph(subgraphNode)` — clones internal nodes, remaps IDs, reconnects boundary:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant G as LGraph
|
||||
participant SGN as SubgraphNode
|
||||
participant SG as Subgraph
|
||||
participant N as LGraphNode
|
||||
|
||||
Caller->>G: unpackSubgraph(subgraphNode)
|
||||
G->>G: beforeChange()
|
||||
|
||||
G->>SG: get internal nodes
|
||||
G->>N: multiClone(internalNodes)
|
||||
|
||||
loop each cloned node
|
||||
G->>G: assign new ID (++lastNodeId)
|
||||
G->>G: nodeIdMap[oldId] = newId
|
||||
G->>G: graph.add(node)
|
||||
G->>N: node.configure(info)
|
||||
G->>N: node.setPos(pos + offset)
|
||||
end
|
||||
|
||||
G->>G: clone and add groups
|
||||
|
||||
Note over G,SG: Remap internal links
|
||||
|
||||
loop each internal link
|
||||
G->>G: remap origin_id/target_id via nodeIdMap
|
||||
opt origin is SUBGRAPH_INPUT_ID
|
||||
G->>G: resolve to external source via subgraphNode.inputs
|
||||
end
|
||||
opt target is SUBGRAPH_OUTPUT_ID
|
||||
G->>G: resolve to external target via subgraphNode.outputs
|
||||
end
|
||||
end
|
||||
|
||||
G->>G: remove(subgraphNode)
|
||||
G->>G: deduplicate links
|
||||
G->>G: create new LLink objects in parent graph
|
||||
G->>G: remap reroute parentIds
|
||||
G->>G: afterChange()
|
||||
```
|
||||
|
||||
Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20) require special-case handling. Boundary link resolution requires looking up the SubgraphNode's slots to find external connections.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
|
||||
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
|
||||
|
||||
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
|
||||
W-->>CS: { graphId, interface }
|
||||
|
||||
CS->>W: query entities where graphScope = graphId
|
||||
W-->>CS: all child entities (nodes, links, reroutes, etc.)
|
||||
|
||||
Note over CS,W: Re-parent entities to containing graph scope
|
||||
|
||||
loop each child entity
|
||||
CS->>W: update graphScope to parent scope
|
||||
end
|
||||
|
||||
Note over CS,W: Reconnect boundary links
|
||||
|
||||
loop each boundary slot in interface
|
||||
CS->>W: getComponent(slotId, SlotConnection)
|
||||
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
|
||||
end
|
||||
|
||||
CS->>W: deleteEntity(subgraphNodeId)
|
||||
CS->>W: remove graphId from scopes registry
|
||||
|
||||
Note over CS,W: Offset positions
|
||||
|
||||
loop each moved entity
|
||||
CS->>W: update Position component
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ----------------- | --------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entities keep their IDs, only graphScope changes |
|
||||
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as slot entities |
|
||||
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Move entity references between scopes |
|
||||
| Link reconnection | Remap origin_id/target_id, create new LLink objects | Update LinkEndpoints component in place |
|
||||
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
|
||||
|
||||
## 6. Connect Slots
|
||||
|
||||
### Current Flow
|
||||
|
||||
`LGraphNode.connectSlots()` — creates link, updates both endpoints, handles reroutes:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant N1 as SourceNode
|
||||
participant N2 as TargetNode
|
||||
participant G as LGraph
|
||||
participant L as LLink
|
||||
participant R as Reroute
|
||||
participant LS as LayoutStore
|
||||
|
||||
Caller->>N1: connectSlots(output, targetNode, input)
|
||||
|
||||
N1->>N1: validate slot types
|
||||
N1->>N2: onConnectInput?() → can reject
|
||||
N1->>N1: onConnectOutput?() → can reject
|
||||
|
||||
opt input already connected
|
||||
N1->>N2: disconnectInput(inputIndex)
|
||||
end
|
||||
|
||||
N1->>L: new LLink(++lastLinkId, type, ...)
|
||||
N1->>G: _links.set(link.id, link)
|
||||
N1->>LS: layoutMutations.createLink()
|
||||
|
||||
N1->>N1: output.links.push(link.id)
|
||||
N1->>N2: input.link = link.id
|
||||
|
||||
loop each reroute in path
|
||||
N1->>R: reroute.linkIds.add(link.id)
|
||||
end
|
||||
|
||||
N1->>G: _version++
|
||||
N1->>N1: onConnectionsChange?(OUTPUT, ...)
|
||||
N1->>N2: onConnectionsChange?(INPUT, ...)
|
||||
N1->>G: setDirtyCanvas()
|
||||
N1->>G: afterChange()
|
||||
```
|
||||
|
||||
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant VS as VersionSystem
|
||||
|
||||
Caller->>CS: connect(world, outputSlotId, inputSlotId)
|
||||
|
||||
CS->>W: getComponent(inputSlotId, SlotConnection)
|
||||
opt already connected
|
||||
CS->>CS: removeLink(world, existingLinkId)
|
||||
end
|
||||
|
||||
CS->>W: createEntity(LinkEntityId)
|
||||
CS->>W: setComponent(linkId, LinkEndpoints, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
|
||||
|
||||
CS->>W: update SlotConnection on outputSlotId (add linkId)
|
||||
CS->>W: update SlotConnection on inputSlotId (set linkId)
|
||||
|
||||
CS->>VS: markChanged()
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (queries World) |
|
||||
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `markChanged()` — one call |
|
||||
| Reroute handling | Manual: iterate chain, add linkId to each | RerouteLinks component updated by system |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Component update: `setComponent(slotId, SlotConnection, ...)` |
|
||||
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
|
||||
|
||||
## 7. Copy / Paste
|
||||
|
||||
### Current Flow
|
||||
|
||||
Copy: serialize selected items → clipboard. Paste: deserialize with new IDs.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant C as LGraphCanvas
|
||||
participant G as LGraph
|
||||
participant N as LGraphNode
|
||||
participant CB as Clipboard
|
||||
|
||||
rect rgb(40, 40, 60)
|
||||
Note over User,CB: Copy
|
||||
User->>C: Ctrl+C
|
||||
C->>C: _serializeItems(selectedItems)
|
||||
loop each selected node
|
||||
C->>N: node.clone().serialize()
|
||||
C->>C: collect input links
|
||||
end
|
||||
C->>C: collect groups, reroutes
|
||||
C->>C: recursively collect subgraph definitions
|
||||
C->>CB: localStorage.setItem(JSON.stringify(data))
|
||||
end
|
||||
|
||||
rect rgb(40, 60, 40)
|
||||
Note over User,CB: Paste
|
||||
User->>C: Ctrl+V
|
||||
C->>CB: localStorage.getItem()
|
||||
C->>C: _deserializeItems(parsed)
|
||||
|
||||
C->>C: remap subgraph IDs (new UUIDs)
|
||||
C->>C: deduplicateSubgraphNodeIds()
|
||||
C->>G: beforeChange()
|
||||
|
||||
loop each subgraph
|
||||
C->>G: createSubgraph(data)
|
||||
end
|
||||
loop each node (id=-1 forces new ID)
|
||||
C->>G: graph.add(node)
|
||||
C->>N: node.configure(info)
|
||||
end
|
||||
loop each reroute
|
||||
C->>G: setReroute(data)
|
||||
C->>C: remap parentIds
|
||||
end
|
||||
loop each link
|
||||
C->>N: outNode.connect(slot, inNode, slot)
|
||||
end
|
||||
|
||||
C->>C: offset positions to cursor
|
||||
C->>C: selectItems(created)
|
||||
C->>G: afterChange()
|
||||
end
|
||||
```
|
||||
|
||||
Problems: clone-serialize-parse-remap-deserialize dance. Every entity type has
|
||||
its own ID remapping logic. Subgraph IDs, node IDs, reroute IDs, and link
|
||||
parent IDs all remapped independently. ~300 lines across multiple methods.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CS as ClipboardSystem
|
||||
participant W as World
|
||||
participant CB as Clipboard
|
||||
|
||||
rect rgb(40, 40, 60)
|
||||
Note over User,CB: Copy
|
||||
User->>CS: copy(world, selectedEntityIds)
|
||||
CS->>W: snapshot all components for selected entities
|
||||
CS->>W: snapshot components for child entities (slots, widgets)
|
||||
CS->>W: snapshot connected links (LinkEndpoints)
|
||||
CS->>CB: store component snapshot
|
||||
end
|
||||
|
||||
rect rgb(40, 60, 40)
|
||||
Note over User,CB: Paste
|
||||
User->>CS: paste(world, position)
|
||||
CS->>CB: retrieve snapshot
|
||||
|
||||
CS->>CS: generate ID remap table (old → new branded IDs)
|
||||
|
||||
loop each entity in snapshot
|
||||
CS->>W: createEntity(newId)
|
||||
loop each component
|
||||
CS->>W: setComponent(newId, type, remappedData)
|
||||
Note over CS,W: entity ID refs in component data<br/>are remapped via table
|
||||
end
|
||||
end
|
||||
|
||||
CS->>CS: offset all Position components to cursor
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Copy format | Clone → serialize → JSON (format depends on class) | Component snapshot (uniform format for all entities) |
|
||||
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | Single remap table applied to all entity ID refs in all components |
|
||||
| Paste reconstruction | `createNode()` → `add()` → `configure()` → `connect()` per node | `createEntity()` → `setComponent()` per entity (flat) |
|
||||
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot includes SubgraphStructure component with entity refs |
|
||||
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
|
||||
|
||||
## Summary: Cross-Cutting Benefits
|
||||
|
||||
| Benefit | Scenarios Where It Applies |
|
||||
| ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| **Atomic operations** | Node Removal, Pack/Unpack — no intermediate inconsistent state |
|
||||
| **No scattered `_version++`** | All scenarios — VersionSystem handles change tracking |
|
||||
| **No callback cascades** | Deserialization, Connect — systems query World instead of firing callbacks |
|
||||
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table instead of per-type logic |
|
||||
| **Entity deletion = cleanup** | Node Removal — `deleteEntity()` removes all components |
|
||||
| **No two-phase creation** | Deserialization — components reference IDs, not instances |
|
||||
| **Move instead of clone** | Pack/Unpack — entities keep their IDs, just change scope |
|
||||
| **Testable in isolation** | All scenarios — mock World, test one system |
|
||||
| **Undo/redo for free** | All scenarios — snapshot components before mutation, restore on undo |
|
||||
722
docs/architecture/ecs-migration-plan.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# ECS Migration Plan
|
||||
|
||||
A phased roadmap for migrating the litegraph entity system to the ECS
|
||||
architecture described in [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
Each phase is independently shippable. Later phases depend on earlier ones
|
||||
unless noted otherwise.
|
||||
|
||||
For the problem analysis, see [Entity Problems](entity-problems.md). For the
|
||||
target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
|
||||
For verified accuracy of these documents, see
|
||||
[Appendix: Critical Analysis](appendix-critical-analysis.md).
|
||||
|
||||
## Planning assumptions
|
||||
|
||||
- The bridge period is expected to span 2-3 release cycles.
|
||||
- Bridge work is treated as transitional debt with explicit owners and sunset
|
||||
checkpoints, not as a permanent architecture layer.
|
||||
- Phase 5 is entered only by explicit go/no-go review against the criteria in
|
||||
this document.
|
||||
|
||||
## Phase 0: Foundation
|
||||
|
||||
Zero behavioral risk. Prepares the codebase for extraction without changing
|
||||
runtime semantics. All items are independently shippable.
|
||||
|
||||
### 0a. Centralize version counter
|
||||
|
||||
`graph._version++` appears in 19 locations across 7 files. The counter is only
|
||||
read once — for debug display in `LGraphCanvas.renderInfo()` (line 5389). It
|
||||
is not used for dirty-checking, caching, or reactivity.
|
||||
|
||||
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
|
||||
increments.
|
||||
|
||||
```
|
||||
incrementVersion(): void {
|
||||
this._version++
|
||||
}
|
||||
```
|
||||
|
||||
| File | Sites |
|
||||
| ---------------------- | ------------------------------------------------------- |
|
||||
| `LGraph.ts` | 5 (lines 956, 989, 1042, 1109, 2643) |
|
||||
| `LGraphNode.ts` | 8 (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567) |
|
||||
| `LGraphCanvas.ts` | 2 (lines 3084, 7880) |
|
||||
| `BaseWidget.ts` | 1 (line 439) |
|
||||
| `SubgraphInput.ts` | 1 (line 137) |
|
||||
| `SubgraphInputNode.ts` | 1 (line 190) |
|
||||
| `SubgraphOutput.ts` | 1 (line 102) |
|
||||
|
||||
**Why first:** Creates the seam where a VersionSystem can later intercept,
|
||||
batch, or replace the mechanism. Mechanical find-and-replace with zero
|
||||
behavioral change.
|
||||
|
||||
**Risk:** None. Existing null guards at call sites are preserved.
|
||||
|
||||
### 0b. Add missing ID type aliases
|
||||
|
||||
`NodeId`, `LinkId`, and `RerouteId` exist as type aliases. Two are missing:
|
||||
|
||||
| Type | Definition | Location |
|
||||
| ----------- | ---------- | ---------------------------------------------------------------- |
|
||||
| `GroupId` | `number` | `LGraphGroup.ts` (currently implicit on `id: number` at line 39) |
|
||||
| `SlotIndex` | `number` | `interfaces.ts` (slot positions are untyped `number` everywhere) |
|
||||
|
||||
**Change:** Add the type aliases, update property declarations, re-export from
|
||||
barrel (`litegraph.ts`).
|
||||
|
||||
**Why:** Foundation for branded IDs. Type aliases are erased at compile time —
|
||||
zero runtime impact.
|
||||
|
||||
**Risk:** None. Type-only change.
|
||||
|
||||
### 0c. Fix architecture doc errors
|
||||
|
||||
Five factual errors verified during code review (see
|
||||
[Appendix](appendix-critical-analysis.md#vii-summary-of-findings)):
|
||||
|
||||
- `entity-problems.md`: `toJSON()` should be `toString()`, `execute()` should
|
||||
be `doExecute()`, method count ~539 should be ~848, `configure()` is ~240
|
||||
lines not ~180
|
||||
- `proto-ecs-stores.md`: `resolveDeepest()` does not exist on
|
||||
PromotedWidgetViewManager; actual methods are `reconcile()` / `getOrCreate()`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Types and World Shell
|
||||
|
||||
Introduces the ECS type vocabulary and an empty World. No migration of existing
|
||||
code — new types coexist with old ones.
|
||||
|
||||
### 1a. Branded entity ID types
|
||||
|
||||
Define branded types in a new `src/ecs/entityId.ts`:
|
||||
|
||||
```
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' } // scope, not entity
|
||||
```
|
||||
|
||||
Add cast helpers (`asNodeEntityId(id: number): NodeEntityId`) for use at
|
||||
system boundaries (deserialization, legacy bridge).
|
||||
|
||||
**Does NOT change existing code.** The branded types are new exports consumed
|
||||
only by new ECS code.
|
||||
|
||||
**Risk:** Low. New files, no modifications to existing code.
|
||||
|
||||
**Consideration:** `NodeId = number | string` is the current type. The branded
|
||||
`NodeEntityId` narrows to `number`. The `string` branch exists solely for
|
||||
subgraph-related nodes (GroupNode hack). The migration must decide whether to:
|
||||
|
||||
- Keep `NodeEntityId = number` and handle the string case at the bridge layer
|
||||
- Or define `NodeEntityId = number | string` with branding (less safe)
|
||||
|
||||
Recommend the former: the bridge layer coerces string IDs to a numeric
|
||||
mapping, and only branded numeric IDs enter the World.
|
||||
|
||||
### 1b. Component interfaces
|
||||
|
||||
Define component interfaces in `src/ecs/components/`:
|
||||
|
||||
```
|
||||
src/ecs/
|
||||
entityId.ts # Branded ID types
|
||||
components/
|
||||
position.ts # Position (shared by Node, Reroute, Group)
|
||||
nodeType.ts # NodeType
|
||||
nodeVisual.ts # NodeVisual
|
||||
connectivity.ts # Connectivity
|
||||
execution.ts # Execution
|
||||
properties.ts # Properties
|
||||
widgetContainer.ts # WidgetContainer
|
||||
linkEndpoints.ts # LinkEndpoints
|
||||
...
|
||||
world.ts # World type and factory
|
||||
```
|
||||
|
||||
Components are TypeScript interfaces only — no runtime code. They mirror
|
||||
the decomposition in ADR 0008 Section "Component Decomposition."
|
||||
|
||||
**Risk:** None. Interface-only files.
|
||||
|
||||
### 1c. World type
|
||||
|
||||
Define the World as a typed container:
|
||||
|
||||
```ts
|
||||
interface World {
|
||||
nodes: Map<NodeEntityId, NodeComponents>
|
||||
links: Map<LinkEntityId, LinkComponents>
|
||||
widgets: Map<WidgetEntityId, WidgetComponents>
|
||||
slots: Map<SlotEntityId, SlotComponents>
|
||||
reroutes: Map<RerouteEntityId, RerouteComponents>
|
||||
groups: Map<GroupEntityId, GroupComponents>
|
||||
scopes: Map<GraphId, GraphId | null> // graph scope DAG (parent or null for root)
|
||||
|
||||
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
|
||||
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
|
||||
getComponent<C>(id: EntityId, component: ComponentKey<C>): C | undefined
|
||||
setComponent<C>(id: EntityId, component: ComponentKey<C>, data: C): void
|
||||
}
|
||||
```
|
||||
|
||||
Subgraphs are not a separate entity kind. A node with a `SubgraphStructure`
|
||||
component represents a subgraph. The `scopes` map tracks the graph nesting DAG.
|
||||
See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full
|
||||
model.
|
||||
|
||||
World scope is per workflow instance. Linked subgraph definitions can be reused
|
||||
across instances, but mutable runtime state (widget values, execution state,
|
||||
selection/transient view state) remains instance-scoped through `graphId`.
|
||||
|
||||
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
|
||||
persistence. The World exists but nothing populates it yet.
|
||||
|
||||
**Risk:** Low. New code, no integration points.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Bridge Layer
|
||||
|
||||
Connects the legacy class instances to the World. Both old and new code can
|
||||
read entity state; writes still go through legacy classes.
|
||||
|
||||
### 2a. Read-only bridge for Position
|
||||
|
||||
The LayoutStore (`src/renderer/core/layout/store/layoutStore.ts`) already
|
||||
extracts position data for nodes, links, and reroutes into Y.js CRDTs. The
|
||||
bridge reads from LayoutStore and populates the World's `Position` component.
|
||||
|
||||
**Approach:** A `PositionBridge` that observes LayoutStore changes and mirrors
|
||||
them into the World. New code reads `world.getComponent(nodeId, Position)`;
|
||||
legacy code continues to read `node.pos` / LayoutStore directly.
|
||||
|
||||
**Open question:** Should the World wrap the Y.js maps or maintain its own
|
||||
plain-data copy? Options:
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| ---------------------- | ------------------------------------- | ----------------------------------------------- |
|
||||
| World wraps Y.js | Single source of truth; no sync lag | World API becomes CRDT-aware; harder to test |
|
||||
| World copies from Y.js | Clean World API; easy to test | Two copies of position data; sync overhead |
|
||||
| World replaces Y.js | Pure ECS; no CRDT dependency in World | Breaks collaboration (ADR 0003); massive change |
|
||||
|
||||
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
|
||||
copy is cheap (position is small data). Revisit if sync overhead becomes
|
||||
measurable.
|
||||
|
||||
**Risk:** Medium. Introduces a sync point between two state systems. Must
|
||||
ensure the bridge doesn't create subtle ordering bugs (e.g., World reads stale
|
||||
position during render).
|
||||
|
||||
### 2b. Read-only bridge for WidgetValue
|
||||
|
||||
WidgetValueStore (`src/stores/widgetValueStore.ts`) already extracts widget
|
||||
state into plain `WidgetState` objects keyed by `graphId:nodeId:name`. This is
|
||||
the closest proto-ECS store.
|
||||
|
||||
**Approach:** A `WidgetBridge` that maps `WidgetValueStore` entries into
|
||||
`WidgetValue` components in the World, keyed by `WidgetEntityId`. Requires
|
||||
assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
||||
|
||||
**Dependency:** Requires 1a (branded IDs) for `WidgetEntityId`.
|
||||
|
||||
**Risk:** Low-Medium. WidgetValueStore is well-structured. Main complexity is
|
||||
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
||||
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
||||
|
||||
### 2c. Read-only bridge for Node metadata
|
||||
|
||||
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
||||
reading from `LGraphNode` instances. These are simple property copies.
|
||||
|
||||
**Approach:** When a node is added to the graph (`LGraph.add()`), the bridge
|
||||
creates the corresponding entity in the World and populates its components.
|
||||
When a node is removed, the bridge deletes the entity.
|
||||
|
||||
The `incrementVersion()` method from Phase 0a becomes the hook point — when
|
||||
version increments, the bridge can re-sync changed components. (This is why
|
||||
centralizing version first matters.)
|
||||
|
||||
**Risk:** Medium. Must handle the full node lifecycle (add, configure, remove)
|
||||
without breaking existing behavior. The bridge is read-only (World mirrors
|
||||
classes, not the reverse), which limits blast radius.
|
||||
|
||||
### Bridge sunset criteria (applies to every Phase 2 bridge)
|
||||
|
||||
A bridge can move from "transitional" to "removal candidate" only when:
|
||||
|
||||
- All production reads for that concern flow through World component queries.
|
||||
- All production writes for that concern flow through system APIs.
|
||||
- Serialization parity tests show no diff between legacy and World paths.
|
||||
- Extension compatibility tests pass without bridge-only fallback paths.
|
||||
|
||||
These criteria prevent the bridge from becoming permanent by default.
|
||||
|
||||
### Bridge duration and maintenance controls
|
||||
|
||||
To contain dual-path maintenance cost during Phases 2-4:
|
||||
|
||||
- Every bridge concern has a named owner and target sunset release.
|
||||
- Every PR touching bridge-covered data paths must include parity tests for both
|
||||
legacy and World-driven execution.
|
||||
- Bridge fallback usage is instrumented in integration/e2e and reviewed every
|
||||
milestone; upward trends block new bridge expansion.
|
||||
- Any bridge that misses its target sunset release requires an explicit risk
|
||||
review and revised removal plan.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Systems
|
||||
|
||||
Introduce system functions that operate on World data. Systems coexist with
|
||||
legacy methods — they don't replace them yet.
|
||||
|
||||
### 3a. SerializationSystem (read-only)
|
||||
|
||||
A function `serializeFromWorld(world: World): SerializedGraph` that produces
|
||||
workflow JSON by querying World components. Run alongside the existing
|
||||
`LGraph.serialize()` in tests to verify equivalence.
|
||||
|
||||
**Why first:** Serialization is read-only and has a clear correctness check
|
||||
(output must match existing serialization). It exercises every component type
|
||||
and proves the World contains sufficient data.
|
||||
|
||||
**Risk:** Low. Runs in parallel with existing code; does not replace it.
|
||||
|
||||
### 3b. VersionSystem
|
||||
|
||||
Replace the `incrementVersion()` method with a system that owns all change
|
||||
tracking. The system observes component mutations on the World and
|
||||
auto-increments the version counter.
|
||||
|
||||
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
|
||||
doesn't see changes).
|
||||
|
||||
**Risk:** Medium. Must not miss any change that the scattered `_version++`
|
||||
currently catches. The 19-site inventory from Phase 0a serves as the test
|
||||
matrix.
|
||||
|
||||
### 3c. ConnectivitySystem (queries only)
|
||||
|
||||
A system that can answer connectivity queries by reading `Connectivity`,
|
||||
`SlotConnection`, and `LinkEndpoints` components from the World:
|
||||
|
||||
- "What nodes are connected to this node's inputs?"
|
||||
- "What links pass through this reroute?"
|
||||
- "What is the execution order?"
|
||||
|
||||
Does not perform mutations yet — just queries. Validates that the World's
|
||||
connectivity data is complete and consistent with the class-based graph.
|
||||
|
||||
**Risk:** Low. Read-only system with equivalence tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Write Path Migration
|
||||
|
||||
Systems begin owning mutations. Legacy class methods delegate to systems.
|
||||
This is the highest-risk phase.
|
||||
|
||||
### 4a. Position writes through World
|
||||
|
||||
New code writes position via `world.setComponent(nodeId, Position, ...)`.
|
||||
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
|
||||
|
||||
**This inverts the data flow:** Phase 2 had legacy -> World (read bridge).
|
||||
Phase 4 has World -> legacy (write bridge). Both paths must work during the
|
||||
transition.
|
||||
|
||||
**Risk:** High. Two-way sync between World and legacy state. Must handle
|
||||
re-entrant updates (World write triggers bridge, which writes to legacy,
|
||||
which must NOT trigger another World write).
|
||||
|
||||
### 4b. ConnectivitySystem mutations
|
||||
|
||||
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
|
||||
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
|
||||
system.
|
||||
|
||||
**Extension API concern:** The current system fires callbacks at each step:
|
||||
|
||||
- `onConnectInput()` / `onConnectOutput()` — can reject connections
|
||||
- `onConnectionsChange()` — notifies after connection change
|
||||
- `onRemoved()` — notifies after node removal
|
||||
|
||||
These callbacks are the **extension API contract**. The ConnectivitySystem
|
||||
must fire them at the same points in the operation, or extensions break.
|
||||
|
||||
**Recommended approach:** The system emits lifecycle events that the bridge
|
||||
layer translates into legacy callbacks. This preserves the contract without
|
||||
the system knowing about the callback API.
|
||||
|
||||
**Phase 4 callback contract (locked):**
|
||||
|
||||
- `onConnectOutput()` and `onConnectInput()` run before any World mutation.
|
||||
- If either callback rejects, abort with no component writes, no version bump,
|
||||
and no lifecycle events.
|
||||
- `onConnectionsChange()` fires synchronously after commit, preserving current
|
||||
source-then-target ordering.
|
||||
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
|
||||
compatibility API during Phase 4.
|
||||
|
||||
**Risk:** High. Extensions depend on callback ordering and timing. Must be
|
||||
validated against real-world extensions.
|
||||
|
||||
### 4c. Widget write path
|
||||
|
||||
Widget value changes go through the World instead of directly through
|
||||
WidgetValueStore. The World's `WidgetValue` component becomes the single
|
||||
source of truth; WidgetValueStore becomes a read-through cache or is removed.
|
||||
|
||||
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
|
||||
change is routing writes through the World instead of the store.
|
||||
|
||||
### 4d. Layout write path and render decoupling
|
||||
|
||||
Remove layout side effects from render incrementally by node family.
|
||||
|
||||
**Approach:**
|
||||
|
||||
1. Inventory `drawNode()` call paths that still trigger `arrange()`.
|
||||
2. For one node family at a time, run `LayoutSystem` in update phase and mark
|
||||
entities as layout-clean before render.
|
||||
3. Keep a temporary compatibility fallback that runs legacy layout only for
|
||||
non-migrated families.
|
||||
4. Delete fallback once parity tests and frame-time budgets are met.
|
||||
|
||||
**Risk:** High. Mixed-mode operation must avoid stale layout reads. Requires
|
||||
family-level rollout and targeted regression tests.
|
||||
|
||||
### Render hot-path performance gate
|
||||
|
||||
Before enabling ECS render reads as default for any migrated family:
|
||||
|
||||
- Benchmark representative workflows (200-node and 500-node minimum).
|
||||
- Compare legacy vs ECS p95 frame time and mean draw cost.
|
||||
- Block rollout on statistically significant regression beyond agreed budget
|
||||
(default budget: 5% p95 frame-time regression ceiling).
|
||||
- Capture profiler traces proving the dominant cost is not repeated
|
||||
`world.getComponent()` lookups.
|
||||
|
||||
### Phase 3 -> 4 gate (required)
|
||||
|
||||
Phase 4 starts only when all of the following are true:
|
||||
|
||||
- A transaction wrapper API exists on the World and is used by connectivity and
|
||||
widget write paths in integration tests.
|
||||
- Undo batching parity is proven: one logical user action yields one undo
|
||||
checkpoint in both legacy and ECS paths.
|
||||
- Callback timing and rejection semantics from Phase 4b are covered by
|
||||
integration tests.
|
||||
- A representative extension suite passes, including `rgthree-comfy`.
|
||||
- Write bridge re-entrancy tests prove there is no World <-> legacy feedback
|
||||
loop.
|
||||
- Layout migration for any enabled node family passes read-only render checks
|
||||
(no `arrange()` writes during draw).
|
||||
- Render hot-path benchmark gate passes for every family moving to ECS-first
|
||||
reads.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Legacy Removal
|
||||
|
||||
Remove bridge layers and deprecated class properties. This phase happens
|
||||
per-component, not all at once.
|
||||
|
||||
### 5a. Remove Position bridge
|
||||
|
||||
Once all position reads and writes go through the World, remove the bridge
|
||||
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
|
||||
|
||||
### 5b. Remove widget class hierarchy
|
||||
|
||||
Once all widget behavior is in systems, the 23+ widget subclasses can be
|
||||
replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
|
||||
`ComboWidget`, etc. become configuration data rather than class instances.
|
||||
|
||||
### 5c. Dissolve god objects
|
||||
|
||||
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is
|
||||
holding the entity ID and delegating to the World. Eventually, they can be
|
||||
removed entirely, replaced by entity ID + component queries.
|
||||
|
||||
**Risk:** Very High. This is the irreversible step. Must be done only after
|
||||
thorough validation that all consumers (including extensions) work with the
|
||||
ECS path.
|
||||
|
||||
### Phase 4 -> 5 exit criteria (required)
|
||||
|
||||
Legacy removal starts only when all of the following are true:
|
||||
|
||||
- The component being removed has no remaining direct reads or writes outside
|
||||
World/system APIs.
|
||||
- Serialization equivalence tests pass continuously for one release cycle.
|
||||
- A representative extension compatibility matrix is green, including
|
||||
`rgthree-comfy`.
|
||||
- Bridge instrumentation shows zero fallback-path usage in integration and e2e
|
||||
suites.
|
||||
- A rollback plan exists for each removal PR until the release is cut.
|
||||
- ECS write path has run as default behind a kill switch for at least one full
|
||||
release cycle.
|
||||
- No unresolved P0/P1 extension regressions are attributed to ECS migration in
|
||||
that cycle.
|
||||
|
||||
### Phase 5 trigger packet (required before first legacy-removal PR)
|
||||
|
||||
The team prepares a single go/no-go packet containing:
|
||||
|
||||
- Phase 4 -> 5 criteria checklist with links to evidence.
|
||||
- Extension compatibility matrix results.
|
||||
- Bridge fallback usage report (must be zero for the target concern).
|
||||
- Performance gate report for ECS render/read paths.
|
||||
- Rollback owner, rollback steps, and release coordination sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### CRDT / ECS coexistence
|
||||
|
||||
The LayoutStore uses Y.js CRDTs for collaboration-ready position data
|
||||
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The ECS World
|
||||
uses plain `Map`s. These must coexist.
|
||||
|
||||
**Options explored in Phase 2a.** The recommended path (World copies from Y.js)
|
||||
defers the hard question. Eventually, the World may need to be CRDT-native —
|
||||
but this requires a separate ADR.
|
||||
|
||||
**Questions to resolve:**
|
||||
|
||||
- Should non-position components also be CRDT-backed for collaboration?
|
||||
- Does the World need an operation log for undo/redo, or can that remain
|
||||
external (Y.js undo manager)?
|
||||
- How does conflict resolution work when two users modify the same component?
|
||||
|
||||
### Extension API preservation
|
||||
|
||||
The current system exposes lifecycle callbacks on entity classes:
|
||||
|
||||
| Callback | Class | Purpose |
|
||||
| --------------------- | ------------ | ----------------------------------- |
|
||||
| `onConnectInput` | `LGraphNode` | Validate/reject incoming connection |
|
||||
| `onConnectOutput` | `LGraphNode` | Validate/reject outgoing connection |
|
||||
| `onConnectionsChange` | `LGraphNode` | React to topology change |
|
||||
| `onRemoved` | `LGraphNode` | Cleanup on deletion |
|
||||
| `onAdded` | `LGraphNode` | Setup on graph insertion |
|
||||
| `onConfigure` | `LGraphNode` | Post-deserialization hook |
|
||||
| `onWidgetChanged` | `LGraphNode` | React to widget value change |
|
||||
|
||||
Extensions register these callbacks to customize node behavior. The ECS
|
||||
migration must preserve this contract or provide a documented migration path
|
||||
for extension authors.
|
||||
|
||||
**Recommended approach:** Define an `EntityLifecycleEvent` system that emits
|
||||
typed events at the same points where callbacks currently fire. The bridge
|
||||
layer translates events into legacy callbacks. Extensions can gradually adopt
|
||||
event listeners instead of callbacks.
|
||||
|
||||
**Phase 4 decisions:**
|
||||
|
||||
- Rejection callbacks act as pre-commit guards (reject before World mutation).
|
||||
- Callback dispatch remains synchronous during the bridge period.
|
||||
- Callback order remains: output validation -> input validation -> commit ->
|
||||
output change notification -> input change notification.
|
||||
|
||||
### Extension Migration Examples (old -> new)
|
||||
|
||||
The bridge keeps legacy callbacks working, but extension authors can migrate
|
||||
incrementally to ECS-native patterns.
|
||||
|
||||
#### 1) Widget lookup by name
|
||||
|
||||
```ts
|
||||
// Legacy pattern
|
||||
const seedWidget = node.widgets?.find((w) => w.name === 'seed')
|
||||
seedWidget?.setValue(42)
|
||||
|
||||
// ECS pattern (using the bridge/world widget lookup index)
|
||||
const seedWidgetId = world.widgetIndex.getByNodeAndName(nodeId, 'seed')
|
||||
if (seedWidgetId) {
|
||||
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
|
||||
if (widgetValue) {
|
||||
world.setComponent(seedWidgetId, WidgetValue, {
|
||||
...widgetValue,
|
||||
value: 42
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2) `onConnectionsChange` callback
|
||||
|
||||
```ts
|
||||
// Legacy pattern
|
||||
nodeType.prototype.onConnectionsChange = function (
|
||||
side,
|
||||
slot,
|
||||
connected,
|
||||
linkInfo
|
||||
) {
|
||||
updateExtensionState(this.id, side, slot, connected, linkInfo)
|
||||
}
|
||||
|
||||
// ECS pattern
|
||||
lifecycleEvents.on('connection.changed', (event) => {
|
||||
if (event.nodeId !== nodeId) return
|
||||
updateExtensionState(
|
||||
event.nodeId,
|
||||
event.side,
|
||||
event.slotIndex,
|
||||
event.connected,
|
||||
event.linkInfo
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
#### 3) `onRemoved` callback
|
||||
|
||||
```ts
|
||||
// Legacy pattern
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
cleanupExtensionResources(this.id)
|
||||
}
|
||||
|
||||
// ECS pattern
|
||||
lifecycleEvents.on('entity.removed', (event) => {
|
||||
if (event.kind !== 'node' || event.entityId !== nodeId) return
|
||||
cleanupExtensionResources(event.entityId)
|
||||
})
|
||||
```
|
||||
|
||||
#### 4) `graph._version++`
|
||||
|
||||
```ts
|
||||
// Legacy pattern (do not add new usages)
|
||||
graph._version++
|
||||
|
||||
// Bridge-safe transitional pattern (Phase 0a)
|
||||
graph.incrementVersion()
|
||||
|
||||
// ECS-native pattern: mutate through command/system API.
|
||||
// VersionSystem bumps once at transaction commit.
|
||||
executor.run({
|
||||
type: 'SetWidgetValue',
|
||||
execute(world) {
|
||||
const value = world.getComponent(widgetId, WidgetValue)
|
||||
if (!value) return
|
||||
world.setComponent(widgetId, WidgetValue, { ...value, value: 42 })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Question to resolve after compatibility parity:**
|
||||
|
||||
- Should ECS-native lifecycle events stay synchronous after bridge removal, or
|
||||
can they become asynchronous once legacy callback compatibility is dropped?
|
||||
|
||||
### Atomicity and transactions
|
||||
|
||||
The ECS lifecycle scenarios claim operations are "atomic." This requires
|
||||
the World to support transactions — the ability to batch multiple component
|
||||
writes and commit or rollback as a unit.
|
||||
|
||||
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
|
||||
checkpoints but not true transactions. The graph can be in an inconsistent
|
||||
state between these calls.
|
||||
|
||||
**Phase 4 baseline semantics:**
|
||||
|
||||
- Mutating systems run inside `world.transaction(label, fn)`.
|
||||
- The bridge maps one World transaction to one `beforeChange()` /
|
||||
`afterChange()` bracket.
|
||||
- Operations with multiple component writes (for example `connect()` touching
|
||||
slots, links, and node metadata) still commit as one transaction and therefore
|
||||
one undo entry.
|
||||
- Failed transactions do not publish partial writes, lifecycle events, or
|
||||
version increments.
|
||||
|
||||
**Questions to resolve:**
|
||||
|
||||
- How should `world.transaction()` interact with Y.js transactions when a
|
||||
component is CRDT-backed?
|
||||
- Is eventual consistency acceptable for derived data updates between
|
||||
transactions, or must post-transaction state always be immediately
|
||||
consistent?
|
||||
|
||||
### Keying strategy unification
|
||||
|
||||
The 6 proto-ECS stores use 6 different keying strategies:
|
||||
|
||||
| Store | Key Format |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` |
|
||||
| DomWidgetStore | Widget UUID |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
||||
| SubgraphNavigationStore | subgraphId or `'root'` |
|
||||
|
||||
The World unifies these under branded entity IDs. But stores that use
|
||||
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
||||
reality — a widget is identified by its relationship to a node. Synthetic
|
||||
`WidgetEntityId`s replace this with an opaque number, requiring a reverse
|
||||
lookup index.
|
||||
|
||||
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
|
||||
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
|
||||
for the transition period.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 0a (incrementVersion) ──┐
|
||||
Phase 0b (ID type aliases) ───┤
|
||||
Phase 0c (doc fixes) ─────────┤── no dependencies between these
|
||||
│
|
||||
Phase 1a (branded IDs) ────────┤
|
||||
Phase 1b (component interfaces) ┤── 1b depends on 1a
|
||||
Phase 1c (World type) ─────────┘── 1c depends on 1a, 1b
|
||||
|
||||
Phase 2a (Position bridge) ────┐── depends on 1c
|
||||
Phase 2b (Widget bridge) ──────┤── depends on 1a, 1c
|
||||
Phase 2c (Node metadata bridge) ┘── depends on 0a, 1c
|
||||
|
||||
Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
|
||||
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
|
||||
Phase 3c (ConnectivitySystem) ──── depends on 2c
|
||||
|
||||
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c
|
||||
|
||||
Phase 4a (Position writes) ────── depends on 2a, 3b
|
||||
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
|
||||
Phase 4c (Widget writes) ─────── depends on 2b
|
||||
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
|
||||
|
||||
Phase 4->5 exit criteria ──────── depends on all of Phase 4
|
||||
|
||||
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
|
||||
```
|
||||
|
||||
## Risk Summary
|
||||
|
||||
| Phase | Risk | Reversibility | Extension Impact |
|
||||
| ------------------ | ---------- | ----------------------- | --------------------------- |
|
||||
| 0 (Foundation) | None | Fully reversible | None |
|
||||
| 1 (Types/World) | Low | New files, deletable | None |
|
||||
| 2 (Bridge) | Low-Medium | Bridge is additive | None |
|
||||
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
|
||||
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
|
||||
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
|
||||
|
||||
The plan is designed so that Phases 0-3 can ship without any risk to
|
||||
extensions or existing behavior. Phase 4 is where the real migration begins,
|
||||
and Phase 5 is the point of no return.
|
||||
568
docs/architecture/ecs-target-architecture.md
Normal file
@@ -0,0 +1,568 @@
|
||||
# ECS Target Architecture
|
||||
|
||||
This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the [current system](entity-interactions.md) transform under ECS, and how the [structural problems](entity-problems.md) are resolved. For the full design rationale, see [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## 1. World Overview
|
||||
|
||||
The World is the single source of truth for runtime entity state in one
|
||||
workflow instance. Entities are just branded IDs. Components are plain data
|
||||
objects. Systems are functions that query the World.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph World["World (Central Registry)"]
|
||||
direction TB
|
||||
NodeStore["Nodes
|
||||
Map<NodeEntityId, NodeComponents>"]
|
||||
LinkStore["Links
|
||||
Map<LinkEntityId, LinkComponents>"]
|
||||
ScopeRegistry["Graph Scopes
|
||||
Map<GraphId, ParentGraphId | null>"]
|
||||
WidgetStore["Widgets
|
||||
Map<WidgetEntityId, WidgetComponents>"]
|
||||
SlotStore["Slots
|
||||
Map<SlotEntityId, SlotComponents>"]
|
||||
RerouteStore["Reroutes
|
||||
Map<RerouteEntityId, RerouteComponents>"]
|
||||
GroupStore["Groups
|
||||
Map<GroupEntityId, GroupComponents>"]
|
||||
end
|
||||
|
||||
subgraph Systems["Systems (Behavior)"]
|
||||
direction TB
|
||||
RS["RenderSystem"]
|
||||
SS["SerializationSystem"]
|
||||
CS["ConnectivitySystem"]
|
||||
LS["LayoutSystem"]
|
||||
ES["ExecutionSystem"]
|
||||
VS["VersionSystem"]
|
||||
end
|
||||
|
||||
RS -->|reads| World
|
||||
SS -->|reads/writes| World
|
||||
CS -->|reads/writes| World
|
||||
LS -->|reads/writes| World
|
||||
ES -->|reads| World
|
||||
VS -->|reads/writes| World
|
||||
|
||||
style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
|
||||
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
|
||||
```
|
||||
|
||||
### Entity IDs
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Branded IDs (compile-time distinct)"
|
||||
NID["NodeEntityId
|
||||
number & { __brand: 'NodeEntityId' }"]
|
||||
LID["LinkEntityId
|
||||
number & { __brand: 'LinkEntityId' }"]
|
||||
WID["WidgetEntityId
|
||||
number & { __brand: 'WidgetEntityId' }"]
|
||||
SLID["SlotEntityId
|
||||
number & { __brand: 'SlotEntityId' }"]
|
||||
RID["RerouteEntityId
|
||||
number & { __brand: 'RerouteEntityId' }"]
|
||||
GID["GroupEntityId
|
||||
number & { __brand: 'GroupEntityId' }"]
|
||||
end
|
||||
|
||||
GRID["GraphId
|
||||
string & { __brand: 'GraphId' }"]:::scopeId
|
||||
|
||||
NID -.-x LID
|
||||
LID -.-x WID
|
||||
WID -.-x SLID
|
||||
|
||||
classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5
|
||||
|
||||
linkStyle 0 stroke:red,stroke-dasharray:5
|
||||
linkStyle 1 stroke:red,stroke-dasharray:5
|
||||
linkStyle 2 stroke:red,stroke-dasharray:5
|
||||
```
|
||||
|
||||
Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected.
|
||||
|
||||
Note: `GraphId` is a scope identifier, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a `SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
|
||||
|
||||
### Linked subgraphs and instance-varying state
|
||||
|
||||
Linked subgraph definitions can be shared structurally, but mutable values are
|
||||
instance-scoped.
|
||||
|
||||
- Shared definition-level data (interface shape, default metadata) can be reused
|
||||
across instances.
|
||||
- Runtime state (`WidgetValue`, execution/transient state, selection) is scoped
|
||||
to the containing `graphId` chain inside one World instance.
|
||||
- "Single source of truth" therefore means one source per workflow instance,
|
||||
not one global source across all linked instances.
|
||||
|
||||
### Recursive subgraphs without inheritance
|
||||
|
||||
Recursive containment is represented through graph scopes rather than
|
||||
`Subgraph extends LGraph` inheritance.
|
||||
|
||||
- A subgraph node points to a child graph via
|
||||
`SubgraphStructure.childGraphId`.
|
||||
- The scope registry stores `childGraphId -> parentGraphId` links.
|
||||
- Depth queries traverse this scope DAG, then filter entities by `graphScope`.
|
||||
|
||||
## 2. Component Composition
|
||||
|
||||
### Node: Before vs After
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Before["LGraphNode (monolith)"]
|
||||
direction TB
|
||||
B1["pos, size, bounding"]
|
||||
B2["color, bgcolor, title"]
|
||||
B3["type, category, nodeData"]
|
||||
B4["inputs[], outputs[]"]
|
||||
B5["order, mode, flags"]
|
||||
B6["properties, properties_info"]
|
||||
B7["widgets[]"]
|
||||
B8["serialize(), configure()"]
|
||||
B9["drawSlots(), drawWidgets()"]
|
||||
B10["execute(), triggerSlot()"]
|
||||
B11["graph._version++"]
|
||||
B12["connect(), disconnect()"]
|
||||
end
|
||||
|
||||
subgraph After["NodeEntityId + Components"]
|
||||
direction TB
|
||||
A1["Position
|
||||
{ pos, size, bounding }"]
|
||||
A2["NodeVisual
|
||||
{ color, bgcolor, boxcolor, title }"]
|
||||
A3["NodeType
|
||||
{ type, category, nodeData }"]
|
||||
A4["Connectivity
|
||||
{ inputSlotIds[], outputSlotIds[] }"]
|
||||
A5["Execution
|
||||
{ order, mode, flags }"]
|
||||
A6["Properties
|
||||
{ properties, propertiesInfo }"]
|
||||
A7["WidgetContainer
|
||||
{ widgetIds[] }"]
|
||||
end
|
||||
|
||||
B1 -.-> A1
|
||||
B2 -.-> A2
|
||||
B3 -.-> A3
|
||||
B4 -.-> A4
|
||||
B5 -.-> A5
|
||||
B6 -.-> A6
|
||||
B7 -.-> A7
|
||||
|
||||
B8 -.->|"moves to"| SYS1["SerializationSystem"]
|
||||
B9 -.->|"moves to"| SYS2["RenderSystem"]
|
||||
B10 -.->|"moves to"| SYS3["ExecutionSystem"]
|
||||
B11 -.->|"moves to"| SYS4["VersionSystem"]
|
||||
B12 -.->|"moves to"| SYS5["ConnectivitySystem"]
|
||||
|
||||
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
### Link: Before vs After
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Before["LLink (class)"]
|
||||
direction TB
|
||||
B1["origin_id, origin_slot
|
||||
target_id, target_slot, type"]
|
||||
B2["color, path, _pos"]
|
||||
B3["_dragging, data"]
|
||||
B4["disconnect()"]
|
||||
B5["resolve()"]
|
||||
end
|
||||
|
||||
subgraph After["LinkEntityId + Components"]
|
||||
direction TB
|
||||
A1["LinkEndpoints
|
||||
{ originId, originSlot,
|
||||
targetId, targetSlot, type }"]
|
||||
A2["LinkVisual
|
||||
{ color, path, centerPos }"]
|
||||
A3["LinkState
|
||||
{ dragging, data }"]
|
||||
end
|
||||
|
||||
B1 -.-> A1
|
||||
B2 -.-> A2
|
||||
B3 -.-> A3
|
||||
B4 -.->|"moves to"| SYS1["ConnectivitySystem"]
|
||||
B5 -.->|"moves to"| SYS2["ConnectivitySystem"]
|
||||
|
||||
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
### Widget: Before vs After
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Before["BaseWidget (class)"]
|
||||
direction TB
|
||||
B1["name, type, _node"]
|
||||
B2["value, options, serialize"]
|
||||
B3["computedHeight, margin"]
|
||||
B4["drawWidget(), onClick()"]
|
||||
B5["useWidgetValueStore()"]
|
||||
B6["usePromotionStore()"]
|
||||
end
|
||||
|
||||
subgraph After["WidgetEntityId + Components"]
|
||||
direction TB
|
||||
A1["WidgetIdentity
|
||||
{ name, widgetType, parentNodeId }"]
|
||||
A2["WidgetValue
|
||||
{ value, options, serialize }"]
|
||||
A3["WidgetLayout
|
||||
{ computedHeight, constraints }"]
|
||||
end
|
||||
|
||||
B1 -.-> A1
|
||||
B2 -.-> A2
|
||||
B3 -.-> A3
|
||||
B4 -.->|"moves to"| SYS1["RenderSystem"]
|
||||
B5 -.->|"absorbed by"| SYS2["World (is the store)"]
|
||||
B6 -.->|"moves to"| SYS3["PromotionSystem"]
|
||||
|
||||
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
## 3. System Architecture
|
||||
|
||||
Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph InputPhase["Input Phase"]
|
||||
UserInput["User Input
|
||||
(pointer, keyboard)"]
|
||||
APIInput["API Input
|
||||
(backend execution results)"]
|
||||
end
|
||||
|
||||
subgraph UpdatePhase["Update Phase (ordered)"]
|
||||
direction TB
|
||||
CS["ConnectivitySystem
|
||||
Manages link/slot mutations.
|
||||
Writes: LinkEndpoints, SlotConnection,
|
||||
Connectivity"]
|
||||
VS["VersionSystem
|
||||
Centralizes change tracking.
|
||||
Replaces 15+ scattered _version++.
|
||||
Writes: version counter"]
|
||||
LS["LayoutSystem
|
||||
Computes positions and sizes.
|
||||
Runs BEFORE render, not during.
|
||||
Reads: Connectivity, WidgetContainer
|
||||
Writes: Position, SlotVisual, WidgetLayout"]
|
||||
ES["ExecutionSystem
|
||||
Determines run order.
|
||||
Reads: Connectivity, Execution
|
||||
Writes: Execution.order"]
|
||||
end
|
||||
|
||||
subgraph RenderPhase["Render Phase (read-only)"]
|
||||
RS["RenderSystem
|
||||
Pure read of components.
|
||||
No state mutation.
|
||||
Reads: Position, *Visual, *Layout"]
|
||||
end
|
||||
|
||||
subgraph PersistPhase["Persist Phase"]
|
||||
SS["SerializationSystem
|
||||
Reads/writes all components.
|
||||
Handles workflow JSON."]
|
||||
end
|
||||
|
||||
UserInput --> CS
|
||||
APIInput --> ES
|
||||
CS --> VS
|
||||
VS --> LS
|
||||
LS --> RS
|
||||
CS --> SS
|
||||
|
||||
style InputPhase fill:#2a2a4a,stroke:#3a3a5a,color:#e0e0e0
|
||||
style UpdatePhase fill:#1a3a2a,stroke:#2a4a3a,color:#e0e0e0
|
||||
style RenderPhase fill:#3a2a1a,stroke:#4a3a2a,color:#e0e0e0
|
||||
style PersistPhase fill:#2a2a3a,stroke:#3a3a4a,color:#e0e0e0
|
||||
```
|
||||
|
||||
### System-Component Access Matrix
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Systems
|
||||
RS["Render"]
|
||||
SS["Serialization"]
|
||||
CS["Connectivity"]
|
||||
LS["Layout"]
|
||||
ES["Execution"]
|
||||
VS["Version"]
|
||||
end
|
||||
|
||||
subgraph Components
|
||||
Pos["Position"]
|
||||
NV["NodeVisual"]
|
||||
NT["NodeType"]
|
||||
Con["Connectivity"]
|
||||
Exe["Execution"]
|
||||
Props["Properties"]
|
||||
WC["WidgetContainer"]
|
||||
LE["LinkEndpoints"]
|
||||
LV["LinkVisual"]
|
||||
SC["SlotConnection"]
|
||||
SV["SlotVisual"]
|
||||
WVal["WidgetValue"]
|
||||
WL["WidgetLayout"]
|
||||
end
|
||||
|
||||
RS -.->|read| Pos
|
||||
RS -.->|read| NV
|
||||
RS -.->|read| LV
|
||||
RS -.->|read| SV
|
||||
RS -.->|read| WL
|
||||
|
||||
LS -->|write| Pos
|
||||
LS -->|write| SV
|
||||
LS -->|write| WL
|
||||
LS -.->|read| Con
|
||||
LS -.->|read| WC
|
||||
|
||||
CS -->|write| LE
|
||||
CS -->|write| SC
|
||||
CS -->|write| Con
|
||||
|
||||
ES -.->|read| Con
|
||||
ES -->|write| Exe
|
||||
|
||||
SS -.->|read/write| Pos
|
||||
SS -.->|read/write| NT
|
||||
SS -.->|read/write| Props
|
||||
SS -.->|read/write| WVal
|
||||
SS -.->|read/write| LE
|
||||
|
||||
VS -.->|read| Pos
|
||||
VS -.->|read| Con
|
||||
```
|
||||
|
||||
## 4. Dependency Flow
|
||||
|
||||
### Before: Tangled References
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Node["LGraphNode"] <-->|"circular"| Graph["LGraph"]
|
||||
Graph <-->|"circular"| Subgraph["Subgraph"]
|
||||
Node -->|"this.graph._links"| Links["LLink Map"]
|
||||
Node -->|"this.graph.getNodeById"| Node
|
||||
Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
|
||||
Canvas -->|"node.graph.remove(node)"| Graph
|
||||
Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
|
||||
Widget -->|"usePromotionStore()"| Store2["Pinia Store"]
|
||||
Node -->|"useLayoutMutations()"| Store3["Layout Store"]
|
||||
Graph -->|"useLayoutMutations()"| Store3
|
||||
LLink["LLink"] -->|"useLayoutMutations()"| Store3
|
||||
|
||||
style Node fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style Graph fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style Canvas fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style Widget fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
### After: Unidirectional Data Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Systems["Systems"]
|
||||
RS["RenderSystem"]
|
||||
CS["ConnectivitySystem"]
|
||||
LS["LayoutSystem"]
|
||||
ES["ExecutionSystem"]
|
||||
SS["SerializationSystem"]
|
||||
VS["VersionSystem"]
|
||||
end
|
||||
|
||||
World["World
|
||||
(instance-scoped source of truth)"]
|
||||
|
||||
subgraph Components["Component Stores"]
|
||||
Pos["Position"]
|
||||
Vis["*Visual"]
|
||||
Con["Connectivity"]
|
||||
Val["*Value"]
|
||||
end
|
||||
|
||||
Systems -->|"query/mutate"| World
|
||||
World -->|"contains"| Components
|
||||
|
||||
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0
|
||||
style Components fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
- **No circular dependencies**: entities are IDs, not class instances
|
||||
- **No Demeter violations**: systems query the World directly, never reach through entities
|
||||
- **No scattered store access**: the World _is_ the store; systems are the only writers
|
||||
- **Unidirectional**: Input → Systems → World → Render (no back-edges)
|
||||
- **Instance safety**: linked definitions can be reused without forcing shared
|
||||
mutable widget/execution state across instances
|
||||
|
||||
## 5. Problem Resolution Map
|
||||
|
||||
How each problem from [entity-problems.md](entity-problems.md) is resolved:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Problems["Current Problems"]
|
||||
P1["God Objects
|
||||
(9k+ line classes)"]
|
||||
P2["Circular Deps
|
||||
(LGraph ↔ Subgraph)"]
|
||||
P3["Mixed Concerns
|
||||
(render + domain + state)"]
|
||||
P4["Inconsistent IDs
|
||||
(number|string, no safety)"]
|
||||
P5["Demeter Violations
|
||||
(graph._links, graph._version++)"]
|
||||
P6["Scattered Side Effects
|
||||
(15+ _version++ sites)"]
|
||||
P7["Render-Time Mutations
|
||||
(arrange() during draw)"]
|
||||
end
|
||||
|
||||
subgraph Solutions["ECS Solutions"]
|
||||
S1["Components: small, focused
|
||||
data objects (5-10 fields each)"]
|
||||
S2["Entities are just IDs.
|
||||
No inheritance hierarchy.
|
||||
Subgraph = node + component."]
|
||||
S3["One system per concern.
|
||||
Systems don't overlap."]
|
||||
S4["Branded per-kind IDs.
|
||||
Compile-time type errors."]
|
||||
S5["Systems query World.
|
||||
No entity→entity refs."]
|
||||
S6["VersionSystem owns
|
||||
all change tracking."]
|
||||
S7["LayoutSystem runs in
|
||||
update phase, before render.
|
||||
RenderSystem is read-only."]
|
||||
end
|
||||
|
||||
P1 --> S1
|
||||
P2 --> S2
|
||||
P3 --> S3
|
||||
P4 --> S4
|
||||
P5 --> S5
|
||||
P6 --> S6
|
||||
P7 --> S7
|
||||
|
||||
style Problems fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style Solutions fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
## 6. Migration Bridge
|
||||
|
||||
The migration is incremental. During the transition, a bridge layer keeps legacy class properties and ECS components in sync.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Legacy as Legacy Code
|
||||
participant Class as LGraphNode (class)
|
||||
participant Bridge as Bridge Adapter
|
||||
participant World as World (ECS)
|
||||
participant New as New Code / Systems
|
||||
|
||||
Note over Legacy,New: Phase 1: Bridge reads from class, writes to World
|
||||
|
||||
Legacy->>Class: node.pos = [100, 200]
|
||||
Class->>Bridge: pos setter intercepted
|
||||
Bridge->>World: world.setComponent(nodeId, Position, { pos: [100, 200] })
|
||||
|
||||
New->>World: world.getComponent(nodeId, Position)
|
||||
World-->>New: { pos: [100, 200], size: [...] }
|
||||
|
||||
Note over Legacy,New: Phase 2: New features build on ECS directly
|
||||
|
||||
New->>World: world.setComponent(nodeId, Position, { pos: [150, 250] })
|
||||
World->>Bridge: change detected
|
||||
Bridge->>Class: node._pos = [150, 250]
|
||||
Legacy->>Class: node.pos
|
||||
Class-->>Legacy: [150, 250]
|
||||
|
||||
Note over Legacy,New: Phase 3: Legacy code migrated, bridge removed
|
||||
|
||||
New->>World: world.getComponent(nodeId, Position)
|
||||
World-->>New: { pos: [150, 250] }
|
||||
```
|
||||
|
||||
### Incremental layout/render separation
|
||||
|
||||
Layout extraction is staged by node family, not all-at-once:
|
||||
|
||||
1. Mark `arrange()` as deprecated in render paths and collect call-site
|
||||
telemetry.
|
||||
2. Run `LayoutSystem` during update for a selected node family behind a feature
|
||||
gate.
|
||||
3. Keep a temporary compatibility fallback for un-migrated node families only.
|
||||
4. Remove the fallback once parity tests and frame-time budgets pass.
|
||||
|
||||
This keeps `RenderSystem` read-only for migrated families while preserving
|
||||
incremental rollout safety.
|
||||
|
||||
### Migration Phases
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Phase1["Phase 1: Types Only"]
|
||||
T1["Define branded IDs"]
|
||||
T2["Define component interfaces"]
|
||||
T3["Define World type"]
|
||||
end
|
||||
|
||||
subgraph Phase2["Phase 2: Bridge"]
|
||||
B1["Bridge adapters
|
||||
class ↔ World sync"]
|
||||
B2["New features use
|
||||
World as source"]
|
||||
B3["Old code unchanged"]
|
||||
end
|
||||
|
||||
subgraph Phase3["Phase 3: Extract"]
|
||||
E1["Migrate one component
|
||||
at a time"]
|
||||
E2["Deprecate class
|
||||
properties"]
|
||||
E3["Systems replace
|
||||
methods"]
|
||||
end
|
||||
|
||||
subgraph Phase4["Phase 4: Clean"]
|
||||
C1["Remove bridge"]
|
||||
C2["Remove legacy classes"]
|
||||
C3["Systems are sole
|
||||
behavior layer"]
|
||||
end
|
||||
|
||||
Phase1 --> Phase2 --> Phase3 --> Phase4
|
||||
|
||||
style Phase1 fill:#1a2a4a,stroke:#2a3a5a,color:#e0e0e0
|
||||
style Phase2 fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
|
||||
style Phase3 fill:#2a3a1a,stroke:#3a4a2a,color:#e0e0e0
|
||||
style Phase4 fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
This diagram is intentionally high level. The operational Phase 4 -> 5 entry
|
||||
criteria (compatibility matrix, bridge fallback usage, rollback requirements)
|
||||
are defined in [ecs-migration-plan.md](ecs-migration-plan.md).
|
||||
349
docs/architecture/ecs-world-command-api.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# World API and Command Layer
|
||||
|
||||
How the ECS World's imperative API relates to ADR 0003's command pattern
|
||||
requirement, and why the two are complementary rather than conflicting.
|
||||
|
||||
This document responds to the concern that `world.setComponent()` and
|
||||
`ConnectivitySystem.connect()` are "imperative mutators" incompatible with
|
||||
serializable, idempotent commands. The short answer: they are the
|
||||
**implementation** of commands, not a replacement for them.
|
||||
|
||||
## Architectural Layering
|
||||
|
||||
```
|
||||
Caller → Command → System (handler) → World (store) → Y.js (sync)
|
||||
↓
|
||||
Command Log (undo, replay, sync)
|
||||
```
|
||||
|
||||
- **Commands** describe intent. They are serializable, deterministic, and
|
||||
idempotent.
|
||||
- **Systems** are command handlers. They validate, execute, and emit lifecycle
|
||||
events.
|
||||
- **The World** is the store. It holds component data. It does not know about
|
||||
commands.
|
||||
|
||||
This is the same relationship Redux has between actions, reducers, and the
|
||||
store. The store's `dispatch()` is imperative. That does not make Redux
|
||||
incompatible with serializable actions.
|
||||
|
||||
## Proposed World Mutation API
|
||||
|
||||
The World exposes a thin imperative surface. Every mutation goes through a
|
||||
system, and every system call is invoked by a command.
|
||||
|
||||
### World Core API
|
||||
|
||||
```ts
|
||||
interface World {
|
||||
// Reads (no command needed)
|
||||
getComponent<C>(id: EntityId, key: ComponentKey<C>): C | undefined
|
||||
hasComponent(id: EntityId, key: ComponentKey<C>): boolean
|
||||
queryAll<C extends ComponentKey[]>(...keys: C): QueryResult<C>[]
|
||||
|
||||
// Mutations (called only by systems, inside transactions)
|
||||
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
|
||||
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
|
||||
setComponent<C>(id: EntityId, key: ComponentKey<C>, data: C): void
|
||||
removeComponent(id: EntityId, key: ComponentKey<C>): void
|
||||
|
||||
// Transaction boundary
|
||||
transaction<T>(label: string, fn: () => T): T
|
||||
}
|
||||
```
|
||||
|
||||
These methods are **internal**. External callers never call
|
||||
`world.setComponent()` directly — they submit commands.
|
||||
|
||||
### Command Interface
|
||||
|
||||
```ts
|
||||
interface Command<T = void> {
|
||||
readonly type: string
|
||||
execute(world: World): T
|
||||
}
|
||||
```
|
||||
|
||||
A command is a plain object with a `type` discriminator and an `execute`
|
||||
method that receives the World. The command executor wraps every
|
||||
`execute()` call in a World transaction.
|
||||
|
||||
### Command Executor
|
||||
|
||||
```ts
|
||||
interface CommandExecutor {
|
||||
run<T>(command: Command<T>): T
|
||||
batch(label: string, commands: Command[]): void
|
||||
}
|
||||
|
||||
function createCommandExecutor(world: World): CommandExecutor {
|
||||
return {
|
||||
run(command) {
|
||||
return world.transaction(command.type, () => command.execute(world))
|
||||
},
|
||||
batch(label, commands) {
|
||||
world.transaction(label, () => {
|
||||
for (const cmd of commands) cmd.execute(world)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every command execution:
|
||||
|
||||
1. Opens a World transaction (maps to one `beforeChange`/`afterChange`
|
||||
bracket for undo).
|
||||
2. Calls the command's `execute()`, which invokes system functions.
|
||||
3. Commits the transaction. On failure, rolls back — no partial writes, no
|
||||
lifecycle events, no version bump.
|
||||
|
||||
## From Imperative Calls to Commands
|
||||
|
||||
The lifecycle scenarios in
|
||||
[ecs-lifecycle-scenarios.md](ecs-lifecycle-scenarios.md) show system calls
|
||||
like `ConnectivitySystem.connect(world, outputSlotId, inputSlotId)`. These
|
||||
are the **internals** of a command. Here is how each scenario maps:
|
||||
|
||||
### Connect Slots
|
||||
|
||||
The lifecycle scenario shows:
|
||||
|
||||
```ts
|
||||
// Inside ConnectivitySystem — this is the handler, not the public API
|
||||
ConnectivitySystem.connect(world, outputSlotId, inputSlotId)
|
||||
```
|
||||
|
||||
The public API is a command:
|
||||
|
||||
```ts
|
||||
const connectSlots: Command = {
|
||||
type: 'ConnectSlots',
|
||||
outputSlotId,
|
||||
inputSlotId,
|
||||
|
||||
execute(world) {
|
||||
ConnectivitySystem.connect(world, this.outputSlotId, this.inputSlotId)
|
||||
}
|
||||
}
|
||||
|
||||
executor.run(connectSlots)
|
||||
```
|
||||
|
||||
The command object is serializable (`{ type, outputSlotId, inputSlotId }`).
|
||||
It can be sent over a wire, stored in a log, or replayed.
|
||||
|
||||
### Move Node
|
||||
|
||||
```ts
|
||||
const moveNode: Command = {
|
||||
type: 'MoveNode',
|
||||
nodeId,
|
||||
pos: [150, 250],
|
||||
|
||||
execute(world) {
|
||||
LayoutSystem.moveNode(world, this.nodeId, this.pos)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remove Node
|
||||
|
||||
```ts
|
||||
const removeNode: Command = {
|
||||
type: 'RemoveNode',
|
||||
nodeId,
|
||||
|
||||
execute(world) {
|
||||
ConnectivitySystem.removeNode(world, this.nodeId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Set Widget Value
|
||||
|
||||
```ts
|
||||
const setWidgetValue: Command = {
|
||||
type: 'SetWidgetValue',
|
||||
widgetId,
|
||||
value,
|
||||
|
||||
execute(world) {
|
||||
world.setComponent(this.widgetId, WidgetValue, {
|
||||
...world.getComponent(this.widgetId, WidgetValue)!,
|
||||
value: this.value
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Batch: Paste
|
||||
|
||||
Paste is a compound operation — many entities created in one undo step:
|
||||
|
||||
```ts
|
||||
const paste: Command = {
|
||||
type: 'Paste',
|
||||
snapshot,
|
||||
offset,
|
||||
|
||||
execute(world) {
|
||||
const remap = new Map<EntityId, EntityId>()
|
||||
|
||||
for (const entity of this.snapshot.entities) {
|
||||
const newId = world.createEntity(entity.kind)
|
||||
remap.set(entity.id, newId)
|
||||
|
||||
for (const [key, data] of entity.components) {
|
||||
world.setComponent(newId, key, remapEntityRefs(data, remap))
|
||||
}
|
||||
}
|
||||
|
||||
// Offset positions
|
||||
for (const [, newId] of remap) {
|
||||
const pos = world.getComponent(newId, Position)
|
||||
if (pos) {
|
||||
world.setComponent(newId, Position, {
|
||||
...pos,
|
||||
pos: [pos.pos[0] + this.offset[0], pos.pos[1] + this.offset[1]]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executor.run(paste) // one transaction, one undo step
|
||||
```
|
||||
|
||||
## Addressing the Six Concerns
|
||||
|
||||
The PR review raised six "critical conflicts." Here is how the command layer
|
||||
resolves each:
|
||||
|
||||
### 1. "The World API is imperative, not command-based"
|
||||
|
||||
Correct — by design. The World is the store. Commands are the public
|
||||
mutation API above it. `world.setComponent()` is to commands what
|
||||
`state[key] = value` is to Redux reducers.
|
||||
|
||||
### 2. "Systems are orchestrators, not command producers"
|
||||
|
||||
Systems are command **handlers**. A command's `execute()` calls system
|
||||
functions. Systems do not spontaneously mutate the World — they are invoked
|
||||
by commands.
|
||||
|
||||
### 3. "Auto-incrementing IDs are non-stable in concurrent environments"
|
||||
|
||||
For local-only operations, auto-increment is fine. For CRDT sync, entity
|
||||
creation goes through a CRDT-aware ID generator (Y.js provides this via
|
||||
`doc.clientID` + logical clock). The command layer can select the ID
|
||||
strategy:
|
||||
|
||||
```ts
|
||||
// Local-only command
|
||||
world.createEntity(kind) // auto-increment
|
||||
|
||||
// CRDT-aware command (future)
|
||||
world.createEntityWithId(kind, crdtGeneratedId)
|
||||
```
|
||||
|
||||
This is an ID generation concern, not an ECS architecture concern.
|
||||
|
||||
### 4. "No transaction primitive exists"
|
||||
|
||||
`world.transaction(label, fn)` is the primitive. It maps to one
|
||||
`beforeChange`/`afterChange` bracket. The command executor wraps every
|
||||
`execute()` call in a transaction. See the [migration plan's Phase 3→4
|
||||
gate](ecs-migration-plan.md#phase-3---4-gate-required) for the acceptance
|
||||
criteria.
|
||||
|
||||
### 5. "No idempotency guarantees"
|
||||
|
||||
Idempotency is a property of the command, not the store. Two strategies:
|
||||
|
||||
- **Content-addressed IDs**: The command specifies the entity ID rather than
|
||||
auto-generating. Replaying the command with the same ID is a no-op if the
|
||||
entity already exists.
|
||||
- **Command deduplication**: The command log tracks applied command IDs.
|
||||
Replaying an already-applied command is skipped.
|
||||
|
||||
Both are standard CRDT patterns and belong in the command executor, not the
|
||||
World.
|
||||
|
||||
### 6. "No error semantics"
|
||||
|
||||
Commands return results. The executor can wrap execution:
|
||||
|
||||
```ts
|
||||
type CommandResult<T> =
|
||||
| { status: 'applied'; value: T }
|
||||
| { status: 'rejected'; reason: string }
|
||||
| { status: 'no-op' }
|
||||
|
||||
function run<T>(command: Command<T>): CommandResult<T> {
|
||||
try {
|
||||
const value = world.transaction(command.type, () => command.execute(world))
|
||||
return { status: 'applied', value }
|
||||
} catch (e) {
|
||||
if (e instanceof RejectionError) {
|
||||
return { status: 'rejected', reason: e.message }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rejection semantics (e.g., `onConnectInput` returning false) throw a
|
||||
`RejectionError` inside the system, which the transaction rolls back.
|
||||
|
||||
## Why Two ADRs
|
||||
|
||||
ADR 0003 defines the command pattern and CRDT sync layer.
|
||||
ADR 0008 defines the entity data model.
|
||||
|
||||
They are **complementary architectural layers**, not competing proposals:
|
||||
|
||||
| Concern | Owns It |
|
||||
| ------------------------- | -------- |
|
||||
| Entity taxonomy and IDs | ADR 0008 |
|
||||
| Component decomposition | ADR 0008 |
|
||||
| World (store) | ADR 0008 |
|
||||
| Command interface | ADR 0003 |
|
||||
| Undo/redo via command log | ADR 0003 |
|
||||
| CRDT sync | ADR 0003 |
|
||||
| Serialization format | ADR 0008 |
|
||||
| Replay and idempotency | ADR 0003 |
|
||||
|
||||
Merging them into a single mega-ADR would conflate the data model with the
|
||||
mutation strategy. Keeping them separate allows each to evolve independently
|
||||
— the World can change its internal representation without affecting the
|
||||
command API, and the command layer can adopt new sync strategies without
|
||||
restructuring the entity model.
|
||||
|
||||
## Relationship to Lifecycle Scenarios
|
||||
|
||||
The [lifecycle scenarios](ecs-lifecycle-scenarios.md) show system-level
|
||||
calls (`ConnectivitySystem.connect()`, `ClipboardSystem.paste()`, etc.).
|
||||
These are the **inside** of a command — what the command handler does when
|
||||
the command is executed.
|
||||
|
||||
The scenarios deliberately omit the command layer to focus on how systems
|
||||
interact with the World. Adding command wrappers is mechanical: every
|
||||
system call shown in the scenarios becomes the body of a command's
|
||||
`execute()` method.
|
||||
|
||||
## When This Gets Built
|
||||
|
||||
The command layer is not part of the initial ECS migration phases (0–3).
|
||||
During Phases 0–3, the bridge layer provides mutation entry points that
|
||||
will later become command handlers. The command layer is introduced in
|
||||
Phase 4 when write paths migrate from legacy to ECS:
|
||||
|
||||
- **Phase 4a**: Position write commands replace direct `node.pos =` assignment
|
||||
- **Phase 4b**: Connectivity commands replace `node.connect()` /
|
||||
`node.disconnect()`
|
||||
- **Phase 4c**: Widget value commands replace direct store writes
|
||||
|
||||
Each Phase 4 step introduces commands for one concern, with the system
|
||||
function as the handler and the World transaction as the atomicity
|
||||
boundary.
|
||||
441
docs/architecture/entity-interactions.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# Entity Interactions (Current System)
|
||||
|
||||
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## Entities
|
||||
|
||||
| Entity | Class | ID Type | Primary Location |
|
||||
| -------- | ------------- | --------------- | ---------------------------------------------------------------------------- |
|
||||
| Graph | `LGraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` |
|
||||
| Node | `LGraphNode` | `NodeId` | `src/lib/litegraph/src/LGraphNode.ts` |
|
||||
| Link | `LLink` | `LinkId` | `src/lib/litegraph/src/LLink.ts` |
|
||||
| Subgraph | `Subgraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` (ECS: node component, not separate entity) |
|
||||
| Widget | `BaseWidget` | name + nodeId | `src/lib/litegraph/src/widgets/BaseWidget.ts` |
|
||||
| Slot | `SlotBase` | index on parent | `src/lib/litegraph/src/node/SlotBase.ts` |
|
||||
| Reroute | `Reroute` | `RerouteId` | `src/lib/litegraph/src/Reroute.ts` |
|
||||
| Group | `LGraphGroup` | `number` | `src/lib/litegraph/src/LGraphGroup.ts` |
|
||||
|
||||
Under the ECS model, subgraphs are not a separate entity kind — they are nodes with `SubgraphStructure` and `SubgraphMeta` components. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
|
||||
|
||||
## 1. Overview
|
||||
|
||||
High-level ownership and reference relationships between all entities.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Legend
|
||||
direction LR
|
||||
L1[A] -->|owns| L2[B]
|
||||
L3[C] -.->|references| L4[D]
|
||||
L5[E] ==>|extends| L6[F]
|
||||
end
|
||||
|
||||
Graph["LGraph
|
||||
(UUID)"]
|
||||
Node["LGraphNode
|
||||
(NodeId)"]
|
||||
SubgraphEntity["Subgraph
|
||||
(UUID)"]
|
||||
SubgraphNode["SubgraphNode"]
|
||||
Link["LLink
|
||||
(LinkId)"]
|
||||
Widget["BaseWidget
|
||||
(name)"]
|
||||
Slot["SlotBase
|
||||
(index)"]
|
||||
Reroute["Reroute
|
||||
(RerouteId)"]
|
||||
Group["LGraphGroup
|
||||
(number)"]
|
||||
Canvas["LGraphCanvas"]
|
||||
|
||||
%% Ownership (solid)
|
||||
Graph -->|"_nodes[]"| Node
|
||||
Graph -->|"_links Map"| Link
|
||||
Graph -->|"reroutes Map"| Reroute
|
||||
Graph -->|"_groups[]"| Group
|
||||
Graph -->|"_subgraphs Map"| SubgraphEntity
|
||||
Node -->|"inputs[], outputs[]"| Slot
|
||||
Node -->|"widgets[]"| Widget
|
||||
|
||||
%% Extends (thick)
|
||||
SubgraphEntity ==>|extends| Graph
|
||||
SubgraphNode ==>|extends| Node
|
||||
|
||||
%% References (dashed)
|
||||
Link -.->|"origin_id, target_id"| Node
|
||||
Link -.->|"parentId"| Reroute
|
||||
Slot -.->|"link / links[]"| Link
|
||||
Reroute -.->|"linkIds"| Link
|
||||
Reroute -.->|"parentId"| Reroute
|
||||
Group -.->|"_children Set"| Node
|
||||
Group -.->|"_children Set"| Reroute
|
||||
SubgraphNode -.->|"subgraph"| SubgraphEntity
|
||||
Node -.->|"graph"| Graph
|
||||
Canvas -.->|"graph"| Graph
|
||||
Canvas -.->|"selectedItems"| Node
|
||||
Canvas -.->|"selectedItems"| Group
|
||||
Canvas -.->|"selectedItems"| Reroute
|
||||
```
|
||||
|
||||
## 2. Connectivity
|
||||
|
||||
How Nodes, Slots, Links, and Reroutes form the graph topology.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph OutputNode["Origin Node"]
|
||||
OSlot["Output Slot
|
||||
links: LinkId[]"]
|
||||
end
|
||||
|
||||
subgraph InputNode["Target Node"]
|
||||
ISlot["Input Slot
|
||||
link: LinkId | null"]
|
||||
end
|
||||
|
||||
OSlot -->|"LinkId ref"| Link["LLink
|
||||
origin_id + origin_slot
|
||||
target_id + target_slot
|
||||
type: ISlotType"]
|
||||
Link -->|"LinkId ref"| ISlot
|
||||
|
||||
Link -.->|"parentId"| R1["Reroute A"]
|
||||
R1 -.->|"parentId"| R2["Reroute B"]
|
||||
|
||||
R1 -.-|"linkIds Set"| Link
|
||||
R2 -.-|"linkIds Set"| Link
|
||||
```
|
||||
|
||||
### Subgraph Boundary Connections
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph ParentGraph["Parent Graph"]
|
||||
ExtNode["External Node"]
|
||||
SGNode["SubgraphNode
|
||||
(in parent graph)"]
|
||||
end
|
||||
|
||||
subgraph SubgraphDef["Subgraph"]
|
||||
SInput["SubgraphInput"]
|
||||
SInputNode["SubgraphInputNode
|
||||
(virtual)"]
|
||||
InternalNode["Internal Node"]
|
||||
SOutputNode["SubgraphOutputNode
|
||||
(virtual)"]
|
||||
SOutput["SubgraphOutput"]
|
||||
end
|
||||
|
||||
ExtNode -->|"Link (parent graph)"| SGNode
|
||||
SGNode -.->|"maps to"| SInput
|
||||
SInput -->|"owns"| SInputNode
|
||||
SInputNode -->|"Link (subgraph)"| InternalNode
|
||||
InternalNode -->|"Link (subgraph)"| SOutputNode
|
||||
SOutputNode -->|"owned by"| SOutput
|
||||
SOutput -.->|"maps to"| SGNode
|
||||
SGNode -->|"Link (parent graph)"| ExtNode
|
||||
```
|
||||
|
||||
### Floating Links (In-Progress Connections)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
|
||||
origin_id=-1 or target_id=-1"]
|
||||
FL -->|"stored in"| FLMap["graph.floatingLinks Map"]
|
||||
FL -.->|"may pass through"| Reroute
|
||||
Reroute -.-|"floatingLinkIds Set"| FL
|
||||
FL -->|"on drop"| Permanent["Permanent LLink
|
||||
(registered in graph._links)"]
|
||||
```
|
||||
|
||||
## 3. Rendering
|
||||
|
||||
How LGraphCanvas draws each entity type.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Canvas["LGraphCanvas
|
||||
render loop"]
|
||||
|
||||
Canvas -->|"1. background"| DrawGroups["drawGroups()"]
|
||||
Canvas -->|"2. connections"| DrawConns["drawConnections()"]
|
||||
Canvas -->|"3. foreground"| DrawNodes["drawNode() per node"]
|
||||
Canvas -->|"4. in-progress"| DrawLC["LinkConnector.renderLinks"]
|
||||
|
||||
DrawGroups --> Group["group.draw(canvas, ctx)"]
|
||||
|
||||
DrawConns --> LinkSeg["LinkSegment interface"]
|
||||
LinkSeg --> Link["LLink path rendering"]
|
||||
LinkSeg --> RerouteRender["Reroute inline rendering
|
||||
(draw, drawSlots)"]
|
||||
|
||||
DrawNodes --> NodeDraw["node drawing pipeline"]
|
||||
NodeDraw -->|"drawSlots()"| SlotDraw["slot.draw() per slot"]
|
||||
NodeDraw -->|"drawWidgets()"| WidgetDraw["widget.drawWidget() per widget"]
|
||||
NodeDraw -->|"title, badges"| NodeChrome["title bar, buttons, badges"]
|
||||
|
||||
DrawLC --> FloatingViz["Floating link visualization"]
|
||||
```
|
||||
|
||||
### Rendering Order Detail
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Canvas
|
||||
participant Gr as Groups
|
||||
participant L as Links/Reroutes
|
||||
participant N as Nodes
|
||||
participant S as Slots
|
||||
participant W as Widgets
|
||||
|
||||
C->>Gr: drawGroups() — background layer
|
||||
Gr-->>C: group shapes + titles
|
||||
|
||||
C->>L: drawConnections() — middle layer
|
||||
L-->>C: bezier paths + reroute dots
|
||||
|
||||
loop each node (back to front)
|
||||
C->>N: drawNode()
|
||||
N->>N: drawNodeShape() (background, title)
|
||||
N->>S: drawSlots() (input/output circles)
|
||||
S-->>N: slot shapes + labels
|
||||
N->>W: drawWidgets() (if not collapsed)
|
||||
W-->>N: widget UI elements
|
||||
N-->>C: complete node
|
||||
end
|
||||
|
||||
C->>C: overlay (tooltips, debug)
|
||||
```
|
||||
|
||||
## 4. Lifecycle
|
||||
|
||||
Creation and destruction flows for each entity.
|
||||
|
||||
### Node Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: new LGraphNode(title)
|
||||
Created --> Configured: node.configure(data)
|
||||
Configured --> InGraph: graph.add(node)
|
||||
|
||||
state InGraph {
|
||||
[*] --> Active
|
||||
Active --> Active: connect/disconnect slots
|
||||
Active --> Active: add/remove widgets
|
||||
Active --> Active: move, resize, collapse
|
||||
}
|
||||
|
||||
InGraph --> Removed: graph.remove(node)
|
||||
Removed --> [*]
|
||||
|
||||
note right of Created
|
||||
Constructor sets defaults.
|
||||
No graph reference yet.
|
||||
end note
|
||||
|
||||
note right of InGraph
|
||||
node.onAdded(graph) called.
|
||||
ID assigned from graph.state.
|
||||
Slots may trigger onConnectionsChange.
|
||||
end note
|
||||
|
||||
note right of Removed
|
||||
All links disconnected.
|
||||
node.onRemoved() called.
|
||||
Removed from graph._nodes.
|
||||
end note
|
||||
```
|
||||
|
||||
### Link Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: node.connect() or connectSlots()
|
||||
Created --> Registered: graph._links.set(id, link)
|
||||
|
||||
state Registered {
|
||||
[*] --> Active
|
||||
Active --> Active: data flows through
|
||||
Active --> Active: reroutes added/removed
|
||||
}
|
||||
|
||||
Registered --> Disconnected: node.disconnectInput/Output()
|
||||
Disconnected --> Removed: link.disconnect(network)
|
||||
Removed --> [*]
|
||||
|
||||
note right of Created
|
||||
new LLink(id, type, origin, slot, target, slot)
|
||||
Output slot.links[] updated.
|
||||
Input slot.link set.
|
||||
end note
|
||||
|
||||
note right of Removed
|
||||
Removed from graph._links.
|
||||
Orphaned reroutes cleaned up.
|
||||
graph._version incremented.
|
||||
end note
|
||||
```
|
||||
|
||||
### Widget Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: node.addWidget(type, name, value, options)
|
||||
Created --> Concrete: toConcreteWidget()
|
||||
Concrete --> Bound: widget.setNodeId(nodeId)
|
||||
|
||||
state Bound {
|
||||
[*] --> Active
|
||||
Active --> Active: setValue() → store + node callback
|
||||
Active --> Active: draw(), onClick(), onDrag()
|
||||
}
|
||||
|
||||
Bound --> Removed: node.removeWidget(widget)
|
||||
Removed --> [*]
|
||||
|
||||
note right of Bound
|
||||
Registered in WidgetValueStore.
|
||||
State keyed by graphId:nodeId:name.
|
||||
Value reads/writes via store.
|
||||
end note
|
||||
```
|
||||
|
||||
### Subgraph Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: graph.createSubgraph(data)
|
||||
|
||||
state Created {
|
||||
[*] --> Defined
|
||||
Defined: registered in rootGraph._subgraphs
|
||||
}
|
||||
|
||||
Created --> Instantiated: new SubgraphNode(subgraph)
|
||||
Instantiated --> InGraph: graph.add(subgraphNode)
|
||||
|
||||
state InGraph {
|
||||
[*] --> Active
|
||||
Active --> Active: add/remove inputs/outputs
|
||||
Active --> Active: promote/demote widgets
|
||||
Active --> Active: edit internal nodes
|
||||
}
|
||||
|
||||
InGraph --> Unpacked: graph.unpackSubgraph(node)
|
||||
Unpacked --> [*]
|
||||
|
||||
InGraph --> NodeRemoved: graph.remove(subgraphNode)
|
||||
NodeRemoved --> MaybePurged: no other SubgraphNodes reference it?
|
||||
MaybePurged --> [*]
|
||||
|
||||
note right of Instantiated
|
||||
SubgraphNode.subgraph = subgraph.
|
||||
Inputs/outputs synced from subgraph.
|
||||
end note
|
||||
|
||||
note right of Unpacked
|
||||
Internal nodes cloned to parent.
|
||||
Links remapped. SubgraphNode removed.
|
||||
Subgraph def removed if unreferenced.
|
||||
end note
|
||||
```
|
||||
|
||||
## 5. State Management
|
||||
|
||||
External stores and their relationships to entities.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Entities
|
||||
Node["LGraphNode"]
|
||||
Widget["BaseWidget"]
|
||||
Reroute["Reroute"]
|
||||
Link["LLink"]
|
||||
Graph["LGraph"]
|
||||
SGNode["SubgraphNode"]
|
||||
end
|
||||
|
||||
subgraph Stores
|
||||
WVS["WidgetValueStore
|
||||
(Pinia)"]
|
||||
PS["PromotionStore
|
||||
(Pinia)"]
|
||||
LM["LayoutMutations
|
||||
(composable)"]
|
||||
end
|
||||
|
||||
subgraph GraphState["Graph Internal State"]
|
||||
Version["graph._version"]
|
||||
LGState["graph.state
|
||||
(lastNodeId, lastLinkId,
|
||||
lastRerouteId, lastGroupId)"]
|
||||
end
|
||||
|
||||
%% WidgetValueStore
|
||||
Widget -->|"setNodeId() registers"| WVS
|
||||
Widget <-->|"value, label, disabled"| WVS
|
||||
WVS -.->|"keyed by graphId:nodeId:name"| Widget
|
||||
|
||||
%% PromotionStore
|
||||
SGNode -->|"tracks promoted widgets"| PS
|
||||
Widget -.->|"isPromotedByAny() query"| PS
|
||||
|
||||
%% LayoutMutations
|
||||
Node -->|"pos/size setter"| LM
|
||||
Reroute -->|"move()"| LM
|
||||
Link -->|"connectSlots()/disconnect()"| LM
|
||||
Graph -->|"add()/remove()"| LM
|
||||
|
||||
%% Graph state
|
||||
Node -->|"connect/disconnect"| Version
|
||||
Widget -->|"setValue()"| Version
|
||||
Node -->|"collapse/toggleAdvanced"| Version
|
||||
Graph -->|"add/remove entities"| LGState
|
||||
```
|
||||
|
||||
### Change Notification Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant E as Entity (Node/Widget/Link)
|
||||
participant G as LGraph
|
||||
participant C as LGraphCanvas
|
||||
participant R as Render Loop
|
||||
|
||||
E->>G: graph._version++
|
||||
E->>G: graph.beforeChange() (undo checkpoint)
|
||||
|
||||
Note over E,G: ... mutation happens ...
|
||||
|
||||
E->>G: graph.afterChange() (undo checkpoint)
|
||||
E->>G: graph.change()
|
||||
G->>C: canvasAction → canvas.setDirty(true, true)
|
||||
C->>R: dirty flags checked on next frame
|
||||
R->>C: full redraw
|
||||
```
|
||||
|
||||
### Widget State Delegation
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant N as Node
|
||||
participant W as Widget
|
||||
participant S as WidgetValueStore
|
||||
participant G as Graph
|
||||
|
||||
N->>W: addWidget(type, name, value)
|
||||
W->>W: toConcreteWidget()
|
||||
N->>W: setNodeId(nodeId)
|
||||
W->>S: registerWidget(graphId, state)
|
||||
S-->>W: state reference stored in widget._state
|
||||
|
||||
Note over W,S: All value access now goes through store
|
||||
|
||||
W->>S: widget.value = newVal (setter)
|
||||
S-->>S: store.state.value = newVal
|
||||
W->>N: node.onWidgetChanged?.(name, val)
|
||||
W->>G: graph._version++
|
||||
```
|
||||
214
docs/architecture/entity-problems.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Entity System Structural Problems
|
||||
|
||||
This document catalogs the structural problems in the current litegraph entity system. It provides the concrete "why" behind the ECS migration proposed in [ADR 0008](../adr/0008-entity-component-system.md). For the as-is relationship map, see [Entity Interactions](entity-interactions.md).
|
||||
|
||||
All file references are relative to `src/lib/litegraph/src/`.
|
||||
|
||||
## 1. God Objects
|
||||
|
||||
The three largest classes carry far too many responsibilities:
|
||||
|
||||
| Class | Lines | Responsibilities |
|
||||
| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `LGraphCanvas` | ~9,100 | Rendering, input handling, selection, link dragging, context menus, clipboard, undo/redo hooks, node layout triggers |
|
||||
| `LGraphNode` | ~4,300 | Domain model, connectivity, serialization, rendering (slots, widgets, badges, title), layout, execution, property management |
|
||||
| `LGraph` | ~3,100 | Container management, serialization, canvas notification, subgraph lifecycle, execution ordering, link deduplication |
|
||||
|
||||
`LGraphNode` alone has ~539 method/property definitions. A sampling of the concerns it mixes:
|
||||
|
||||
| Concern | Examples |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Rendering | `renderingColor` (line 328), `renderingBgColor` (line 335), `drawSlots()`, `drawWidgets()`, `measure(ctx)` (line 2074) |
|
||||
| Serialization | `serialize()` (line 943), `configure()` (line 831), `toJSON()` (line 1033) |
|
||||
| Connectivity | `connect()`, `connectSlots()`, `disconnectInput()`, `disconnectOutput()` |
|
||||
| Execution | `execute()` (line 1418), `triggerSlot()` |
|
||||
| Layout | `arrange()`, `_arrangeWidgets()`, `computeSize()` |
|
||||
| State mgmt | `setProperty()`, `onWidgetChanged()`, direct `graph._version++` |
|
||||
|
||||
## 2. Circular Dependencies
|
||||
|
||||
**LGraph ↔ Subgraph**: `Subgraph` extends `LGraph`, but `LGraph` creates and manages `Subgraph` instances. This forces:
|
||||
|
||||
- A barrel export in `litegraph.ts` that re-exports 40+ modules with **order-dependent imports**
|
||||
- An explicit comment at `litegraph.ts:15`: _"Must remain above LiteGraphGlobal (circular dependency due to abstract factory behaviour in 'configure')"_
|
||||
- Test files must use the barrel import (`import { LGraph, Subgraph } from '.../litegraph'`) rather than direct imports, or they break
|
||||
|
||||
The `Subgraph` class is defined inside `LGraph.ts` (line 2761) rather than in its own file — a symptom of the circular dependency being unresolvable with the current class hierarchy.
|
||||
|
||||
## 3. Mixed Concerns
|
||||
|
||||
### Rendering in Domain Objects
|
||||
|
||||
`LGraphNode.measure()` (line 2074) accepts a `CanvasRenderingContext2D` parameter and sets `ctx.font` — a rendering operation embedded in what should be a domain model:
|
||||
|
||||
```
|
||||
measure(ctx?: CanvasRenderingContext2D, options?: MeasureOptions): void {
|
||||
...
|
||||
if (ctx) ctx.font = this.innerFontStyle
|
||||
```
|
||||
|
||||
### State Mutation During Render
|
||||
|
||||
`LGraphCanvas.drawNode()` (line 5554) mutates node state as a side effect of rendering:
|
||||
|
||||
- Line 5562: `node._setConcreteSlots()` — rebuilds slot arrays
|
||||
- Line 5564: `node.arrange()` — recalculates widget layout
|
||||
- Lines 5653-5655: same mutations repeated for a second code path
|
||||
|
||||
This means the render pass is not idempotent — drawing a node changes its state.
|
||||
|
||||
### Store Dependencies in Domain Objects
|
||||
|
||||
`BaseWidget` (line 20-22) imports two Pinia stores at the module level:
|
||||
|
||||
- `usePromotionStore` — queried on every `getOutlineColor()` call
|
||||
- `useWidgetValueStore` — widget state delegation via `setNodeId()`
|
||||
|
||||
Similarly, `LGraph` (lines 10-13) imports `useLayoutMutations`, `usePromotionStore`, and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
|
||||
|
||||
### Serialization Interleaved with Container Logic
|
||||
|
||||
`LGraph.configure()` (line 2400) mixes deserialization, event dispatch, store clearing, and container state setup in a single 180-line method. A change to serialization format risks breaking container lifecycle, and vice versa.
|
||||
|
||||
## 4. Inconsistent ID Systems
|
||||
|
||||
### Ambiguous NodeId
|
||||
|
||||
```ts
|
||||
export type NodeId = number | string // LGraphNode.ts:100
|
||||
```
|
||||
|
||||
Most nodes use numeric IDs, but subgraph-related nodes use strings. Code must use runtime type guards (`typeof node.id === 'number'` at LGraph.ts:978, LGraphCanvas.ts:9045). This is a source of subtle bugs.
|
||||
|
||||
### Magic Numbers
|
||||
|
||||
```ts
|
||||
export const SUBGRAPH_INPUT_ID = -10 // constants.ts:8
|
||||
export const SUBGRAPH_OUTPUT_ID = -20 // constants.ts:11
|
||||
```
|
||||
|
||||
Negative sentinel values in the ID space. Links check `origin_id === SUBGRAPH_INPUT_ID` to determine if they cross a subgraph boundary — a special case baked into the general-purpose `LLink` class.
|
||||
|
||||
### No Independent Widget or Slot IDs
|
||||
|
||||
**Widgets** are identified by `name + parent node`. Code searches by name in multiple places:
|
||||
|
||||
- `LGraphNode.ts:904` — `this.inputs.find((i) => i.widget?.name === w.name)`
|
||||
- `LGraphNode.ts:4077` — `slot.widget.name === widget.name`
|
||||
- `LGraphNode.ts:4086` — `this.widgets?.find((w) => w.name === slot.widget.name)`
|
||||
|
||||
If a widget is renamed, all these lookups silently break.
|
||||
|
||||
**Slots** are identified by their array index on the parent node. The serialized link format (`SerialisedLLinkArray`) stores slot indices:
|
||||
|
||||
```ts
|
||||
type SerialisedLLinkArray = [
|
||||
id,
|
||||
origin_id,
|
||||
origin_slot,
|
||||
target_id,
|
||||
target_slot,
|
||||
type
|
||||
]
|
||||
```
|
||||
|
||||
If slots are reordered (e.g., by an extension adding a slot), all links referencing that node become stale.
|
||||
|
||||
### No Cross-Kind ID Safety
|
||||
|
||||
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. This is the core motivation for the branded ID types proposed in ADR 0008.
|
||||
|
||||
## 5. Law of Demeter Violations
|
||||
|
||||
Entities routinely reach through their container to access internal state and sibling entities.
|
||||
|
||||
### Nodes Reaching Into Graph Internals
|
||||
|
||||
8+ locations in `LGraphNode` access the graph's private `_links` map directly:
|
||||
|
||||
- Line 877: `this.graph._links.get(input.link)`
|
||||
- Line 891: `this.graph._links.get(linkId)`
|
||||
- Line 1254: `const link_info = this.graph._links.get(input.link)`
|
||||
|
||||
Nodes also reach through the graph to access sibling nodes' slots:
|
||||
|
||||
- Line 1150: `this.graph.getNodeById(link.origin_id)` → read origin's outputs
|
||||
- Line 1342: `this.graph.getNodeById(link.target_id)` → read target's inputs
|
||||
- Line 1556: `node.inputs[link_info.target_slot]` (accessing a sibling's slot by index)
|
||||
|
||||
### Canvas Mutating Graph Internals
|
||||
|
||||
`LGraphCanvas` directly increments the graph's version counter:
|
||||
|
||||
- Line 3084: `node.graph._version++`
|
||||
- Line 7880: `node.graph._version++`
|
||||
|
||||
The canvas also reaches through nodes to their container:
|
||||
|
||||
- Line 8337: `node.graph.remove(node)` — canvas deletes a node by reaching through the node to its graph
|
||||
|
||||
### Entities Mutating Container State
|
||||
|
||||
`LGraphNode` directly mutates `graph._version++` from 8+ locations (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567). There is no encapsulated method for signaling a version change — every call site manually increments the counter.
|
||||
|
||||
## 6. Scattered Side Effects
|
||||
|
||||
### Version Counter
|
||||
|
||||
`graph._version` is incremented from **15+ locations** across three files:
|
||||
|
||||
| File | Locations |
|
||||
| ----------------- | --------------------------------------------------- |
|
||||
| `LGraph.ts` | Lines 956, 989, 1042, 1109, 2643 |
|
||||
| `LGraphNode.ts` | Lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567 |
|
||||
| `LGraphCanvas.ts` | Lines 3084, 7880 |
|
||||
|
||||
No central mechanism exists. It's easy to forget an increment (stale render) or add a redundant one (wasted work).
|
||||
|
||||
### Module-Scope Store Access
|
||||
|
||||
Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime:
|
||||
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()` (module scope)
|
||||
- `Reroute.ts` — same pattern at module scope
|
||||
- `BaseWidget.ts:20-22` — imports `usePromotionStore` and `useWidgetValueStore`
|
||||
|
||||
These make the domain objects untestable without a Vue app context.
|
||||
|
||||
### Change Notification Sprawl
|
||||
|
||||
`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from
|
||||
**12+ locations** in `LGraphCanvas` alone (lines 1574, 1592, 1604, 1620, 1752,
|
||||
1770, 8754, 8760, 8771, 8777, 8803, 8811). These calls are grouping brackets:
|
||||
misplaced or missing pairs can split one logical operation across multiple undo
|
||||
entries, while unmatched extra calls can delay checkpoint emission until the
|
||||
nesting counter returns to zero.
|
||||
|
||||
## 7. Render-Time Mutations
|
||||
|
||||
The render pass is not pure — it mutates state as a side effect:
|
||||
|
||||
| Location | Mutation |
|
||||
| ----------------------------------- | ------------------------------------------------------------------- |
|
||||
| `LGraphCanvas.drawNode()` line 5562 | `node._setConcreteSlots()` — rebuilds concrete slot arrays |
|
||||
| `LGraphCanvas.drawNode()` line 5564 | `node.arrange()` — recalculates widget positions and sizes |
|
||||
| `BaseWidget.getOutlineColor()` | Queries `PromotionStore` on every frame |
|
||||
| Link rendering | Caches `_pos` center point and `_centreAngle` on the LLink instance |
|
||||
|
||||
This means:
|
||||
|
||||
- Rendering order matters (later nodes see side effects from earlier nodes)
|
||||
- Performance profiling conflates render cost with layout cost
|
||||
- Concurrent or partial renders would produce inconsistent state
|
||||
|
||||
## How ECS Addresses These Problems
|
||||
|
||||
| Problem | ECS Solution |
|
||||
| ---------------------- | ----------------------------------------------------------------------------- |
|
||||
| God objects | Data split into small, focused components; behavior lives in systems |
|
||||
| Circular dependencies | Entities are just IDs; components have no inheritance hierarchy |
|
||||
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
|
||||
| Inconsistent IDs | Branded per-kind IDs with compile-time safety |
|
||||
| Demeter violations | Systems query the World directly; no entity-to-entity references |
|
||||
| Scattered side effects | Version tracking becomes a system responsibility; stores become systems |
|
||||
| Render-time mutations | Render system reads components without writing; layout system runs separately |
|
||||
376
docs/architecture/proto-ecs-stores.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# Proto-ECS: Existing State Extraction
|
||||
|
||||
The codebase has already begun extracting entity state into external Pinia stores — an organic, partial migration toward the ECS principles described in [ADR 0008](../adr/0008-entity-component-system.md). This document catalogs those stores, analyzes how they align with the ECS target, and identifies what remains to be extracted.
|
||||
|
||||
For the full problem analysis, see [Entity Problems](entity-problems.md). For the ECS target, see [ECS Target Architecture](ecs-target-architecture.md).
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Six stores extract entity state out of class instances into centralized, queryable registries:
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
## 2. WidgetValueStore
|
||||
|
||||
**File:** `src/stores/widgetValueStore.ts`
|
||||
|
||||
The closest thing to a true ECS component store in the codebase today.
|
||||
|
||||
### State Shape
|
||||
|
||||
```
|
||||
Map<UUID, Map<WidgetKey, WidgetState>>
|
||||
│ │ │
|
||||
graphId "nodeId:name" pure data object
|
||||
```
|
||||
|
||||
`WidgetState` is a plain data object with no methods:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
| ----------- | ---------------- | ------------------------------------------ |
|
||||
| `nodeId` | `NodeId` | Owning node |
|
||||
| `name` | `string` | Widget name |
|
||||
| `type` | `string` | Widget type (e.g., `'number'`, `'toggle'`) |
|
||||
| `value` | `TWidgetValue` | Current value |
|
||||
| `label` | `string?` | Display label |
|
||||
| `disabled` | `boolean?` | Disabled state |
|
||||
| `serialize` | `boolean?` | Whether to include in workflow JSON |
|
||||
| `options` | `IWidgetOptions` | Configuration |
|
||||
|
||||
### Two-Phase Delegation
|
||||
|
||||
**Phase 1 — Construction:** Widget creates a local `_state` object with initial values.
|
||||
|
||||
**Phase 2 — `setNodeId()`:** Widget replaces its `_state` with a reference to the store's object:
|
||||
|
||||
```
|
||||
widget._state = useWidgetValueStore().registerWidget(graphId, { ...this._state, nodeId })
|
||||
```
|
||||
|
||||
After registration, the widget's getters/setters (`value`, `label`, `disabled`) are pass-throughs to the store. Mutations to the widget automatically sync to the store via shared object reference.
|
||||
|
||||
### What's Extracted vs What Remains
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Extracted["Extracted to Store"]
|
||||
style Extracted fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
V["value"]
|
||||
L["label"]
|
||||
D["disabled"]
|
||||
S["serialize"]
|
||||
O["options (ref)"]
|
||||
end
|
||||
|
||||
subgraph Remains["Remains on Class"]
|
||||
style Remains fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
Node["_node (back-ref)"]
|
||||
Draw["drawWidget(), drawWidgetShape()"]
|
||||
Events["onClick(), onDrag(), onPointerDown()"]
|
||||
Layout["y, computedHeight, width"]
|
||||
CB["callback, linkedWidgets"]
|
||||
DOM["element (DOM widgets)"]
|
||||
end
|
||||
|
||||
BW["BaseWidget"] --> Extracted
|
||||
BW --> Remains
|
||||
```
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| --------------------------- | -------- | ------------------------------------------------- |
|
||||
| `WidgetState` is plain data | Yes | No methods, serializable, reactive |
|
||||
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` cleans up |
|
||||
| Query API | Yes | `getWidget()`, `getNodeWidgets()` |
|
||||
| Cross-subgraph sync | Yes | Same nodeId:name shares state across depths |
|
||||
| Back-reference (`_node`) | **No** | Widget still holds owning node ref |
|
||||
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
|
||||
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
|
||||
|
||||
## 3. PromotionStore
|
||||
|
||||
**File:** `src/stores/promotionStore.ts`
|
||||
|
||||
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
|
||||
|
||||
### State Shape
|
||||
|
||||
```
|
||||
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
|
||||
│ │ │
|
||||
graphId subgraphNodeId ordered promotion entries
|
||||
|
||||
graphRefCounts: Map<UUID, Map<string, number>>
|
||||
│ │ │
|
||||
graphId entryKey count of nodes promoting this widget
|
||||
```
|
||||
|
||||
### Ref-Counting for O(1) Queries
|
||||
|
||||
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
|
||||
|
||||
```ts
|
||||
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
|
||||
// O(1) lookup: refCounts.get(key) > 0
|
||||
```
|
||||
|
||||
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
|
||||
|
||||
### View Reconciliation Layer
|
||||
|
||||
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
PS["PromotionStore
|
||||
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
|
||||
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
|
||||
(proxy widget)"]
|
||||
PV -->|"resolveDeepest()"| CW["Concrete Widget
|
||||
(leaf node)"]
|
||||
PV -->|"reads value"| WVS["WidgetValueStore"]
|
||||
```
|
||||
|
||||
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
|
||||
| Ref-counted queries | Yes | Efficient global state queries without scanning |
|
||||
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
|
||||
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
|
||||
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
|
||||
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
|
||||
|
||||
## 4. LayoutStore (CRDT)
|
||||
|
||||
**File:** `src/renderer/core/layout/store/layoutStore.ts`
|
||||
|
||||
The most architecturally advanced extraction — uses Y.js CRDTs for collaboration-ready position state.
|
||||
|
||||
### State Shape
|
||||
|
||||
```
|
||||
ynodes: Y.Map<NodeLayoutMap> // nodeId → { pos, size, zIndex, bounds }
|
||||
ylinks: Y.Map<Y.Map<...>> // linkId → link layout data
|
||||
yreroutes: Y.Map<Y.Map<...>> // rerouteId → reroute layout data
|
||||
```
|
||||
|
||||
### Write API
|
||||
|
||||
`useLayoutMutations()` (`src/renderer/core/layout/operations/layoutMutations.ts`) provides the mutation API:
|
||||
|
||||
- `moveNode(graphId, nodeId, pos)`
|
||||
- `resizeNode(graphId, nodeId, size)`
|
||||
- `setNodeZIndex(graphId, nodeId, zIndex)`
|
||||
- `createLink(graphId, linkId, ...)`
|
||||
- `removeLink(graphId, linkId)`
|
||||
- `moveReroute(graphId, rerouteId, pos)`
|
||||
|
||||
### The Scattered Access Problem
|
||||
|
||||
This composable is called at **module scope** in domain objects:
|
||||
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()`
|
||||
- `Reroute.ts` — same pattern
|
||||
- `LGraphNode.ts` — imported and called in methods
|
||||
|
||||
These module-scope calls create implicit dependencies on the Vue runtime and make the domain objects untestable without a full app context.
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------- | --------- | --------------------------------------------------- |
|
||||
| Position data extracted | Yes | Closest to the ECS `Position` component |
|
||||
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
|
||||
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
|
||||
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
|
||||
| Module-scope access | **No** | Domain objects import store at module level |
|
||||
| No entity ID branding | **No** | Plain numbers, no type safety across kinds |
|
||||
|
||||
## 5. Pattern Analysis
|
||||
|
||||
### What These Stores Have in Common (Proto-ECS)
|
||||
|
||||
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
|
||||
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
|
||||
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
|
||||
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
|
||||
|
||||
### What's Missing vs Full ECS
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Have["What We Have"]
|
||||
style Have fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
H1["Centralized data stores"]
|
||||
H2["Plain data components
|
||||
(WidgetState, LayoutMap)"]
|
||||
H3["Query APIs
|
||||
(getWidget, isPromotedByAny)"]
|
||||
H4["Graph-scoped lifecycle"]
|
||||
H5["Partial position extraction
|
||||
(LayoutStore)"]
|
||||
end
|
||||
|
||||
subgraph Missing["What's Missing"]
|
||||
style Missing fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
M1["Unified World
|
||||
(6 stores, 6 keying strategies)"]
|
||||
M2["Branded entity IDs
|
||||
(keys are string concatenations)"]
|
||||
M3["System layer
|
||||
(mutations from anywhere)"]
|
||||
M4["Complete extraction
|
||||
(behavior still on classes)"]
|
||||
M5["No entity-to-entity refs
|
||||
(back-refs remain)"]
|
||||
M6["Render/update separation
|
||||
(stores queried during render)"]
|
||||
end
|
||||
```
|
||||
|
||||
### Keying Strategy Comparison
|
||||
|
||||
Each store invents its own identity scheme:
|
||||
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
|
||||
## 6. Extraction Map
|
||||
|
||||
Current state of extraction for each entity kind:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Node["LGraphNode"]
|
||||
N_ext["Extracted:
|
||||
- pos, size → LayoutStore
|
||||
- zIndex → LayoutStore"]
|
||||
N_rem["Remains on class:
|
||||
- type, category, nodeData
|
||||
- color, bgcolor, boxcolor
|
||||
- inputs[], outputs[]
|
||||
- widgets[]
|
||||
- properties
|
||||
- order, mode, flags
|
||||
- serialize(), configure()
|
||||
- drawSlots(), drawWidgets()
|
||||
- connect(), disconnect()"]
|
||||
end
|
||||
|
||||
subgraph Widget["BaseWidget"]
|
||||
W_ext["Extracted:
|
||||
- value → WidgetValueStore
|
||||
- label → WidgetValueStore
|
||||
- disabled → WidgetValueStore
|
||||
- promotion status → PromotionStore
|
||||
- DOM pos/vis → DomWidgetStore"]
|
||||
W_rem["Remains on class:
|
||||
- _node back-ref
|
||||
- drawWidget()
|
||||
- onClick(), onDrag()
|
||||
- computedHeight
|
||||
- callback, linkedWidgets"]
|
||||
end
|
||||
|
||||
subgraph Link["LLink"]
|
||||
L_ext["Extracted:
|
||||
- layout data → LayoutStore"]
|
||||
L_rem["Remains on class:
|
||||
- origin_id, target_id
|
||||
- origin_slot, target_slot
|
||||
- type, color, path
|
||||
- data, _dragging
|
||||
- disconnect(), resolve()"]
|
||||
end
|
||||
|
||||
subgraph Reroute["Reroute"]
|
||||
R_ext["Extracted:
|
||||
- pos → LayoutStore"]
|
||||
R_rem["Remains on class:
|
||||
- parentId, linkIds
|
||||
- floatingLinkIds
|
||||
- color, draw()
|
||||
- findSourceOutput()"]
|
||||
end
|
||||
|
||||
subgraph Group["LGraphGroup"]
|
||||
G_ext["Extracted:
|
||||
(nothing)"]
|
||||
G_rem["Remains on class:
|
||||
- pos, size, bounding
|
||||
- title, font, color
|
||||
- _children, _nodes
|
||||
- draw(), move()
|
||||
- recomputeInsideNodes()"]
|
||||
end
|
||||
|
||||
subgraph Subgraph["Subgraph (node component)"]
|
||||
S_ext["Extracted:
|
||||
- promotions → PromotionStore"]
|
||||
S_rem["Remains on class:
|
||||
- name, description
|
||||
- inputs[], outputs[]
|
||||
- inputNode, outputNode
|
||||
- All LGraph state"]
|
||||
end
|
||||
|
||||
style N_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
style W_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
style L_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
style R_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
style G_ext fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style S_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
|
||||
style N_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style W_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style L_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style R_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style G_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style S_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
## 7. Migration Gap Analysis
|
||||
|
||||
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
Based on existing progress and problem severity:
|
||||
|
||||
1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction)
|
||||
2. **Node Position** — already in LayoutStore, needs branded ID and formal component type
|
||||
3. **Link** — small component set, high coupling pain
|
||||
4. **Slot** — no extraction yet, but small and self-contained
|
||||
5. **Reroute** — partially extracted, moderate complexity
|
||||
6. **Group** — no extraction, but least coupled to other entities
|
||||
7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md)
|
||||
574
docs/architecture/subgraph-boundaries-and-promotion.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Subgraph Boundaries and Widget Promotion
|
||||
|
||||
A companion to [ADR 0008](../adr/0008-entity-component-system.md). Where the ADR
|
||||
defines the entity taxonomy and component decomposition, this document examines
|
||||
the three questions the ADR defers — questions that turn out to be facets of a
|
||||
single deeper insight.
|
||||
|
||||
For the structural problems motivating this work, see
|
||||
[Entity Problems](entity-problems.md). For the target architecture, see
|
||||
[ECS Target Architecture](ecs-target-architecture.md). For the phased migration
|
||||
roadmap, see [ECS Migration Plan](ecs-migration-plan.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Graph Model Unification
|
||||
|
||||
### The false distinction
|
||||
|
||||
Consider a subgraph. It contains nodes, links, reroutes, groups. It has inputs
|
||||
and outputs. It can be serialized, deserialized, copied, pasted. It has an
|
||||
execution order. It has a version counter.
|
||||
|
||||
Now consider a graph. It contains nodes, links, reroutes, groups. It has inputs
|
||||
and outputs (to the execution backend). It can be serialized, deserialized,
|
||||
copied, pasted. It has an execution order. It has a version counter.
|
||||
|
||||
These are the same thing.
|
||||
|
||||
The current codebase almost knows this. `Subgraph extends LGraph` — the
|
||||
inheritance hierarchy encodes the identity. But it encodes it as a special case
|
||||
of a general case, when in truth there is no special case. A subgraph is not a
|
||||
_kind_ of graph. A subgraph _is_ a graph. The root workflow is not a privileged
|
||||
container — it is simply a graph that happens to have no parent.
|
||||
|
||||
This is the pattern that appears everywhere in nature and mathematics: the
|
||||
Mandelbrot set, the branching of rivers, the structure of lungs. At every scale,
|
||||
the same shape. A graph that contains a node that contains a graph that contains
|
||||
a node that contains a graph. The part is isomorphic to the whole.
|
||||
|
||||
### What the code says
|
||||
|
||||
Three symptoms reveal the false distinction:
|
||||
|
||||
1. **`Subgraph` lives inside `LGraph.ts`** (line 2761). It cannot be extracted
|
||||
to its own file because the circular dependency between `Subgraph` and
|
||||
`LGraph` is unresolvable under the current inheritance model. The
|
||||
architecture is telling us, in the only language it has, that these two
|
||||
classes want to be one thing.
|
||||
|
||||
2. **`Subgraph` overrides `state` to delegate to `rootGraph.state`** (line
|
||||
2790). A subgraph does not own its own ID counters — it borrows them from the
|
||||
root. It is not independent. It never was.
|
||||
|
||||
3. **Execution flattens the hierarchy anyway.** `SubgraphNode.getInnerNodes()`
|
||||
dissolves the nesting boundary to produce a flat execution order. The runtime
|
||||
already treats the hierarchy as transparent. Only the data model pretends
|
||||
otherwise.
|
||||
|
||||
### The unified model
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Current["Current: Inheritance Hierarchy"]
|
||||
direction TB
|
||||
LG["LGraph (base)"]
|
||||
SG["Subgraph (extends LGraph)"]
|
||||
LGN["LGraphNode (base)"]
|
||||
SGN["SubgraphNode (extends LGraphNode)"]
|
||||
LG -.->|"_subgraphs Map"| SG
|
||||
SG ==>|"extends"| LG
|
||||
SGN ==>|"extends"| LGN
|
||||
SGN -.->|".subgraph"| SG
|
||||
end
|
||||
|
||||
subgraph Unified["Unified: Composition"]
|
||||
direction TB
|
||||
W["World (flat)"]
|
||||
N1["Node A
|
||||
graphScope: root"]
|
||||
N2["Node B (subgraph carrier)
|
||||
graphScope: root
|
||||
+ SubgraphStructure component"]
|
||||
N3["Node C
|
||||
graphScope: graph-2"]
|
||||
N4["Node D
|
||||
graphScope: graph-2"]
|
||||
W --- N1
|
||||
W --- N2
|
||||
W --- N3
|
||||
W --- N4
|
||||
N2 -.->|"SubgraphStructure.graphId"| GS2["graph-2"]
|
||||
end
|
||||
|
||||
style Current fill:#2a1a1a,stroke:#4a2a2a,color:#e0e0e0
|
||||
style Unified fill:#1a2a1a,stroke:#2a4a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
In the ECS World:
|
||||
|
||||
- **Every graph is a graph.** The "root" graph is simply the one whose
|
||||
`graphScope` has no parent.
|
||||
- **Nesting is a component, not a type.** A node can carry a
|
||||
`SubgraphStructure` component, which references another graph scope. That
|
||||
scope contains its own entities — nodes, links, widgets, reroutes, groups —
|
||||
all living in the same flat World.
|
||||
- **One World per workflow.** All entities across all nesting levels coexist in
|
||||
a single World, each tagged with a `graphScope` identifier. There are no
|
||||
sub-worlds, no recursive containers. The fractal structure is encoded in the
|
||||
data, not in the container hierarchy.
|
||||
- **Entity taxonomy: six kinds, not seven.** ADR 0008 defines seven entity kinds
|
||||
including `SubgraphEntityId`. Under unification, "subgraph" is not an entity
|
||||
kind — it is a node with a component. The taxonomy becomes: Node, Link,
|
||||
Widget, Slot, Reroute, Group.
|
||||
- **ID counters remain global.** All entity IDs are allocated from a single
|
||||
counter space, shared across all nesting levels. This preserves the current
|
||||
`rootGraph.state` behavior and guarantees ID uniqueness across the entire
|
||||
World.
|
||||
- **Graph scope parentage is tracked.** The World maintains a scope registry:
|
||||
each `graphId` maps to its parent `graphId` (or null for the root). This
|
||||
enables the ancestor walk required by the acyclicity invariant and supports
|
||||
queries like "all entities transitively contained by this graph."
|
||||
|
||||
### The acyclicity invariant
|
||||
|
||||
Self-similarity is beautiful, but recursion without a base case is catastrophe.
|
||||
A subgraph node must not contain a graph that contains itself, directly or
|
||||
through any chain of nesting.
|
||||
|
||||
The current code handles this with a blunt instrument:
|
||||
`Subgraph.MAX_NESTED_SUBGRAPHS = 1000`. This limits depth but does not prevent
|
||||
cycles. A graph that references itself at depth 1 is just as broken as one that
|
||||
does so at depth 1001.
|
||||
|
||||
The proper invariant is structural:
|
||||
|
||||
> The `graphScope` reference graph induced by `SubgraphStructure.graphId` must
|
||||
> form a directed acyclic graph (DAG). Before creating or modifying a
|
||||
> `SubgraphStructure`, the system must verify that the target `graphId` is not
|
||||
> an ancestor of the containing graph's scope.
|
||||
|
||||
This is a simple ancestor walk — from the proposed target graph, follow parent
|
||||
references upward. If the containing graph appears in the chain, the operation
|
||||
is rejected. The check runs on every mutation that creates or modifies a
|
||||
`SubgraphStructure` component, not as an offline validation pass. A cycle in the
|
||||
live graph is unrecoverable; prevention must be synchronous.
|
||||
|
||||
---
|
||||
|
||||
## 2. Graph Boundary Model
|
||||
|
||||
### How boundaries work today
|
||||
|
||||
When a link crosses from a parent graph into a subgraph, the current system
|
||||
interposes three layers of indirection:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Parent["Parent Graph"]
|
||||
EN["External Node"]
|
||||
SGN["SubgraphNode"]
|
||||
end
|
||||
|
||||
subgraph Magic["Boundary Infrastructure"]
|
||||
SI["SubgraphInput
|
||||
(slot-like object)"]
|
||||
SIN["SubgraphInputNode
|
||||
(virtual node, ID = -10)"]
|
||||
end
|
||||
|
||||
subgraph Interior["Subgraph Interior"]
|
||||
IN["Internal Node"]
|
||||
end
|
||||
|
||||
EN -->|"Link 1
|
||||
(parent graph)"| SGN
|
||||
SGN -.->|"maps to"| SI
|
||||
SI -->|"owns"| SIN
|
||||
SIN -->|"Link 2
|
||||
(subgraph, magic ID)"| IN
|
||||
|
||||
style Magic fill:#3a2a1a,stroke:#5a3a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
Two separate links. A virtual node with a magic sentinel ID
|
||||
(`SUBGRAPH_INPUT_ID = -10`). A slot-like object that is neither a slot nor a
|
||||
node. Every link in the system carries a latent special case: _am I a boundary
|
||||
link?_ Every pack/unpack operation must remap both links and reconcile both ID
|
||||
spaces.
|
||||
|
||||
This complexity exists because the boundary was never designed — it accreted.
|
||||
The virtual nodes are wiring infrastructure with no domain semantics. The magic
|
||||
IDs are an escape hatch from a type system that offers no legitimate way to
|
||||
express "this connection crosses a scope boundary."
|
||||
|
||||
### Boundaries as typed contracts
|
||||
|
||||
A subgraph boundary is, mathematically, a function signature. A subgraph takes
|
||||
typed inputs and produces typed outputs. The types come from the same vocabulary
|
||||
used by all nodes: `"INT"`, `"FLOAT"`, `"MODEL"`, `"IMAGE"`, `"CONDITIONING"`,
|
||||
and any custom type registered by extensions.
|
||||
|
||||
The boundary model should say exactly this:
|
||||
|
||||
```
|
||||
SubgraphStructure {
|
||||
graphId: GraphId
|
||||
interface: {
|
||||
inputs: Array<{ name: string, type: ISlotType, slotId: SlotEntityId }>
|
||||
outputs: Array<{ name: string, type: ISlotType, slotId: SlotEntityId }>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `interface` is the contract. Each entry declares a name, a type, and a
|
||||
reference to the corresponding slot on the SubgraphNode. Inside the subgraph,
|
||||
internal nodes connect to boundary slots through ordinary links — no virtual
|
||||
nodes, no magic IDs, no special cases. A link is a link. A slot is a slot.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Parent["Parent Graph"]
|
||||
EN["External Node"]
|
||||
SGN["SubgraphNode
|
||||
+ SubgraphStructure"]
|
||||
EN -->|"ordinary link"| SGN
|
||||
end
|
||||
|
||||
subgraph Interface["Typed Interface"]
|
||||
I1["input: 'seed'
|
||||
type: INT
|
||||
slotId: S-42"]
|
||||
O1["output: 'image'
|
||||
type: IMAGE
|
||||
slotId: S-43"]
|
||||
end
|
||||
|
||||
subgraph Interior["Subgraph (graphScope: G-2)"]
|
||||
IN1["KSampler"]
|
||||
IN2["VAEDecode"]
|
||||
IN1 -->|"ordinary link"| IN2
|
||||
end
|
||||
|
||||
SGN -.->|"interface.inputs[0]"| I1
|
||||
I1 -.->|"boundary link"| IN1
|
||||
IN2 -.->|"boundary link"| O1
|
||||
O1 -.->|"interface.outputs[0]"| SGN
|
||||
|
||||
style Interface fill:#1a2a3a,stroke:#2a3a5a,color:#e0e0e0
|
||||
```
|
||||
|
||||
### Type-driven widget surfacing
|
||||
|
||||
The existing type system already knows which types get widgets. When a node
|
||||
definition declares an input of type `"INT"`, the widget registry
|
||||
(`widgetStore.widgets`) maps that type to a number widget constructor. When the
|
||||
type is `"MODEL"`, no widget constructor exists — the input is socket-only.
|
||||
|
||||
This mechanism applies identically to subgraph interface inputs. If a subgraph
|
||||
declares an input of type `"INT"`, the SubgraphNode gets an INT input slot, and
|
||||
the type → widget mapping gives that slot a number widget. If the input is type
|
||||
`"MODEL"`, the SubgraphNode gets a socket-only input. No special promotion
|
||||
machinery is needed. The type system already does the work.
|
||||
|
||||
This is the key insight that connects graph boundaries to widget promotion, and
|
||||
it is the subject of Section 3.
|
||||
|
||||
### Pack and unpack
|
||||
|
||||
Under graph unification, packing and unpacking become operations on `graphScope`
|
||||
tags rather than on class hierarchies:
|
||||
|
||||
**Pack** (convert selection to subgraph):
|
||||
|
||||
1. Create a new `graphId`
|
||||
2. Move selected entities: change their `graphScope` to the new graph
|
||||
3. For links that crossed the selection boundary: create boundary slot mappings
|
||||
in `SubgraphStructure.interface`, infer types from the connected slots
|
||||
4. Create a SubgraphNode in the parent scope with the `SubgraphStructure`
|
||||
component
|
||||
|
||||
**Unpack** (dissolve subgraph):
|
||||
|
||||
1. Move entities back: change their `graphScope` to the parent
|
||||
2. Reconnect boundary links directly (remove the SubgraphNode intermediary)
|
||||
3. Delete the SubgraphNode
|
||||
|
||||
The critical simplification: **no ID remapping.** Entities keep their IDs
|
||||
throughout. Only the `graphScope` tag changes. The current system's
|
||||
clone-remap-configure dance — separate logic for node IDs, link IDs, reroute
|
||||
IDs, subgraph UUIDs — is eliminated entirely.
|
||||
|
||||
---
|
||||
|
||||
## 3. Widget Promotion: Open Decision
|
||||
|
||||
Widget promotion is the mechanism by which an interior widget surfaces on the
|
||||
SubgraphNode in the parent graph. A user right-clicks a widget inside a subgraph
|
||||
and selects "Promote to parent." The widget's value becomes controllable from the
|
||||
outside.
|
||||
|
||||
This is where the document presents two candidates for the ECS model. The team
|
||||
must choose before Phase 3 of the migration.
|
||||
|
||||
### Current mechanism
|
||||
|
||||
The current system has three layers:
|
||||
|
||||
1. **PromotionStore** (`src/stores/promotionStore.ts`): A ref-counted Pinia
|
||||
store mapping `graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracks
|
||||
which interior widgets are promoted and provides O(1) `isPromotedByAny()`
|
||||
queries.
|
||||
|
||||
2. **PromotedWidgetViewManager**: A reconciliation layer that maintains stable
|
||||
`PromotedWidgetView` proxy widget objects, diffing against the store on each
|
||||
update — a pattern analogous to virtual DOM reconciliation.
|
||||
|
||||
3. **PromotedWidgetView**: A proxy widget on the SubgraphNode that mirrors the
|
||||
interior widget's type, value, and options. Reads and writes delegate to the
|
||||
original widget's entry in `WidgetValueStore`.
|
||||
|
||||
Serialized as `properties.proxyWidgets` on the SubgraphNode.
|
||||
|
||||
### Candidate A: Connections-only
|
||||
|
||||
Promotion is not a separate mechanism. It is adding a typed input to the
|
||||
subgraph's interface.
|
||||
|
||||
When a user "promotes" widget X (type `INT`) on interior node N:
|
||||
|
||||
1. A new entry is added to `SubgraphStructure.interface.inputs`:
|
||||
`{ name: "seed", type: "INT", slotId: <new slot> }`
|
||||
2. The SubgraphNode gains a new input slot of type `INT`. The type → widget
|
||||
mapping (`widgetStore`) creates an INT widget on that slot automatically.
|
||||
3. Inside the subgraph, the interior node's widget input is replaced by a
|
||||
connection from the boundary input — the same transformation that occurs
|
||||
today when a user drags a link to a widget input (`forceInput` behavior).
|
||||
|
||||
"Demoting" is the reverse: remove the interface input, restore the interior
|
||||
widget to its standalone state.
|
||||
|
||||
The PromotionStore, PromotedWidgetViewManager, and PromotedWidgetView are
|
||||
eliminated entirely. The existing slot, link, and widget infrastructure handles
|
||||
everything. Promotion becomes an operation on the subgraph's function signature,
|
||||
not a parallel state management system.
|
||||
|
||||
**Value flow under Candidate A:**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant SW as SubgraphNode Widget (INT)
|
||||
participant BS as Boundary Slot
|
||||
participant IW as Interior Node Input
|
||||
participant Exec as Execution
|
||||
|
||||
User->>SW: sets value = 42
|
||||
Note over SW: normal widget, normal WidgetValueStore entry
|
||||
SW->>BS: value carried by boundary link
|
||||
BS->>IW: arrives as input connection value
|
||||
Exec->>IW: reads input value (42)
|
||||
```
|
||||
|
||||
### Candidate B: Simplified component promotion
|
||||
|
||||
Promotion remains a first-class concept, simplified from three layers to one:
|
||||
|
||||
- A `WidgetPromotion` component on a widget entity:
|
||||
`{ promotedTo: NodeEntityId, sourceWidget: WidgetEntityId }`
|
||||
- The SubgraphNode's widget list includes promoted widget entity IDs directly
|
||||
- Value reads/writes delegate to the source widget's `WidgetValue` component via
|
||||
World lookup
|
||||
- Serialized as `properties.proxyWidgets` (unchanged)
|
||||
|
||||
This removes the ViewManager and proxy widget reconciliation but preserves the
|
||||
concept of promotion as distinct from connection.
|
||||
|
||||
### Tradeoff matrix
|
||||
|
||||
| Dimension | A: Connections-Only | B: Simplified Promotion |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| New concepts | None — reuses slots, links, widgets | `WidgetPromotion` component |
|
||||
| Code removed | PromotionStore, ViewManager, PromotedWidgetView, `_syncPromotions` | ViewManager, proxy reconciliation |
|
||||
| Shared subgraph compat | ✅ Each instance has independent interface inputs with independent values | ⚠️ Promotion delegates to a source widget by entity ID — when multiple SubgraphNode instances share a definition, which instance's source widget is authoritative? |
|
||||
| Dynamic widgets | ✅ Input type drives widget creation via existing registry | ⚠️ Must handle type changes in promotion component |
|
||||
| Serialization | Interface inputs serialized as `SubgraphIO` entries | Separate `proxyWidgets` property |
|
||||
| Backward-compatible loading | Migration: old `proxyWidgets` → interface inputs + boundary links | Direct — same serialization shape |
|
||||
| UX consistency | Promoted widgets look like normal input widgets | Promoted widgets look like proxy widgets (distinct) |
|
||||
| Widget ordering | Slot ordering (reorderable like any input) | Explicit promotion order (`movePromotion`) |
|
||||
| Nested promotion | Adding interface inputs at each nesting level — simple mechanically, but N levels = N manual promote operations for the user | `disambiguatingSourceNodeId` complexity persists |
|
||||
|
||||
### Constraints that hold regardless
|
||||
|
||||
Whichever candidate is chosen:
|
||||
|
||||
- **`WidgetEntityId` is internal.** Serialization uses widget name + parent node
|
||||
reference. This is settled (see Section 4).
|
||||
- **The type → widget mapping is authoritative.** The widget registry
|
||||
(`widgetStore.widgets`) is the single source of truth for which types produce
|
||||
widgets. No parallel mechanism should duplicate this.
|
||||
- **Backward-compatible loading is non-negotiable.** Existing workflows with
|
||||
`proxyWidgets` must load correctly, indefinitely (see Section 4).
|
||||
- **The design must not foreclose functional subgraphs.** A future where
|
||||
subgraphs behave as pure functions — same inputs, same outputs, no
|
||||
instance-specific state beyond inputs — must remain reachable. This is a
|
||||
constraint, not a current requirement.
|
||||
|
||||
### Recommendation and decision criteria
|
||||
|
||||
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
|
||||
truth: promotion is adding a typed input to a function signature. The type
|
||||
system already handles widget creation for typed inputs. Building a parallel
|
||||
mechanism for "promoted widgets" is building a second, narrower version of
|
||||
something the system already does.
|
||||
|
||||
The cost of A is a migration path for existing `proxyWidgets` serialization. On
|
||||
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
|
||||
inputs and boundary links. This is a one-time ratchet conversion — once
|
||||
loaded and re-saved, the workflow uses the new format.
|
||||
|
||||
**Choose B if** the team determines that promoted widgets must remain
|
||||
visually or behaviorally distinct from normal input widgets in ways the type →
|
||||
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
|
||||
the current release cycle's capacity.
|
||||
|
||||
**Decision needed before** Phase 3 of the ECS migration, when systems are
|
||||
introduced and the widget/connectivity architecture solidifies.
|
||||
|
||||
---
|
||||
|
||||
## 4. Serialization Boundary
|
||||
|
||||
### Principle
|
||||
|
||||
The internal model and the serialization format are different things, designed
|
||||
for different purposes.
|
||||
|
||||
The World is optimized for runtime queries: branded IDs for type safety, flat
|
||||
entity maps for O(1) lookup, component composition for flexible querying. The
|
||||
serialization format is optimized for interchange: named keys for human
|
||||
readability, nested structure for self-containment, positional widget value
|
||||
arrays for compactness, backward compatibility across years of workflow files.
|
||||
|
||||
These are not the same optimization targets. Conflating them — forcing the
|
||||
internal model to mirror the wire format, or vice versa — creates a system that
|
||||
is mediocre at both jobs. The `SerializationSystem` is the membrane between
|
||||
these two worlds. It translates in both directions, and it is the only component
|
||||
that knows about legacy format quirks.
|
||||
|
||||
### Current serialization structure
|
||||
|
||||
```
|
||||
ISerialisedGraph {
|
||||
nodes: ISerialisedNode[] // widgets_values[], inputs[], outputs[]
|
||||
links: SerialisedLLinkArray[] // [id, origin_id, origin_slot, target_id, target_slot, type]
|
||||
reroutes: SerialisableReroute[]
|
||||
groups: ISerialisedGroup[]
|
||||
subgraphs: ExportedSubgraph[] // recursive: each contains its own nodes, links, etc.
|
||||
}
|
||||
```
|
||||
|
||||
Subgraphs nest recursively. Widget values are positional arrays on each node.
|
||||
Link data uses tuple arrays for compactness.
|
||||
|
||||
### What changes under graph unification
|
||||
|
||||
**Internal model**: Flat. All entities across all nesting levels live in one
|
||||
World, tagged with `graphScope`. No recursive containers.
|
||||
|
||||
**Serialized format**: Nested. The `SerializationSystem` walks the scope tree
|
||||
and produces the recursive `ExportedSubgraph` structure, matching the current
|
||||
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
|
||||
see no change.
|
||||
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ---------------------------------------- |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
|
||||
|
||||
The "ratchet conversion" pattern: load any supported format, normalize to the
|
||||
internal model. The system accepts old formats indefinitely but produces the
|
||||
current format on save.
|
||||
|
||||
### Widget identity at the boundary
|
||||
|
||||
| Context | Identity | Example |
|
||||
| -------------------- | ---------------------------------------------------------- | ---------------------------------- |
|
||||
| **Internal (World)** | `WidgetEntityId` (opaque branded number) | `42 as WidgetEntityId` |
|
||||
| **Serialized** | Position in `widgets_values[]` + name from node definition | `widgets_values[2]` → third widget |
|
||||
|
||||
On save: the `SerializationSystem` queries `WidgetIdentity.name` and
|
||||
`WidgetValue.value`, produces the positional array ordered by widget creation
|
||||
order.
|
||||
|
||||
On load: widget values are matched by name against the node definition's input
|
||||
specs, then assigned `WidgetEntityId`s from the global counter.
|
||||
|
||||
This is the existing contract, preserved exactly.
|
||||
|
||||
### Subgraph interface at the boundary
|
||||
|
||||
Under graph unification, the subgraph's typed interface
|
||||
(`SubgraphStructure.interface`) is serialized as the existing `SubgraphIO`
|
||||
format:
|
||||
|
||||
```
|
||||
SubgraphIO {
|
||||
id: UUID
|
||||
name: string
|
||||
type: string // slot type (e.g. "INT", "MODEL")
|
||||
linkIds?: LinkId[]
|
||||
}
|
||||
```
|
||||
|
||||
If Candidate A (connections-only promotion) is chosen: promoted widgets become
|
||||
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
|
||||
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
|
||||
migration). On save, `proxyWidgets` is no longer written.
|
||||
|
||||
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
|
||||
serialized in its current format.
|
||||
|
||||
### Backward-compatible loading contract
|
||||
|
||||
This is a hard constraint with no expiration:
|
||||
|
||||
1. **Any workflow saved by any prior version of ComfyUI must load
|
||||
successfully.** The `SerializationSystem` is the sole custodian of legacy
|
||||
format knowledge — positional widget arrays, magic link IDs, `SubgraphIO`
|
||||
shapes, `proxyWidgets`, tuple-encoded links.
|
||||
|
||||
2. **The rest of the system never sees legacy formats.** On load, all data is
|
||||
normalized to ECS components. No other system, component, or query path needs
|
||||
to know that `SUBGRAPH_INPUT_ID = -10` ever existed.
|
||||
|
||||
3. **New format features are additive.** Old fields are never removed from the
|
||||
accepted schema. They may be deprecated in documentation, but the parser
|
||||
accepts them indefinitely.
|
||||
|
||||
4. **Save format may evolve.** The output format can change (e.g., dropping
|
||||
`proxyWidgets` in favor of interface inputs under Candidate A), but only when
|
||||
the corresponding load-path migration is in place and validated.
|
||||
|
||||
---
|
||||
|
||||
## 5. Impact on ADR 0008
|
||||
|
||||
This document proposes or surfaces the following changes to
|
||||
[ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Area | Current ADR 0008 | Proposed Change |
|
||||
| ------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| Entity taxonomy | 7 kinds including `SubgraphEntityId` | 6 kinds — subgraph is a node with `SubgraphStructure` component |
|
||||
| `SubgraphEntityId` | `string & { __brand: 'SubgraphEntityId' }` | Eliminated; replaced by `GraphId` scope identifier |
|
||||
| Subgraph components | `SubgraphStructure`, `SubgraphMeta` listed as separate-entity components | Become node components on SubgraphNode entities |
|
||||
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
|
||||
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
|
||||
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
|
||||
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
|
||||
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
|
||||
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
|
||||
|
||||
These amendments should be applied to ADR 0008 and the related architecture
|
||||
documents in a follow-up pass after team review of this document:
|
||||
|
||||
- [ECS Target Architecture](ecs-target-architecture.md) — World Overview
|
||||
diagram, Entity IDs diagram, component tables
|
||||
- [ECS Migration Plan](ecs-migration-plan.md) — Phase 1c World type definition,
|
||||
dependency graph
|
||||
- [ECS Lifecycle Scenarios](ecs-lifecycle-scenarios.md) — unpack flow uses
|
||||
`subgraphEntityId`
|
||||
- [Proto-ECS Stores](proto-ecs-stores.md) — extraction map lists Subgraph as
|
||||
distinct entity kind
|
||||
- [Entity Interactions](entity-interactions.md) — entity table and overview
|
||||
diagram list Subgraph separately
|
||||
@@ -29,7 +29,9 @@ The ComfyUI Frontend project uses **colocated tests** - test files are placed al
|
||||
Our tests use the following frameworks and libraries:
|
||||
|
||||
- [Vitest](https://vitest.dev/) - Test runner and assertion library
|
||||
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities
|
||||
- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing
|
||||
- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation
|
||||
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
|
||||
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -27,6 +27,17 @@ const config: KnipConfig = {
|
||||
},
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'apps/website': {
|
||||
entry: [
|
||||
'src/pages/**/*.astro',
|
||||
'src/layouts/**/*.astro',
|
||||
'src/components/**/*.vue',
|
||||
'src/styles/global.css',
|
||||
'astro.config.ts'
|
||||
],
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts,json,yaml}': (stagedFiles: string[]) => {
|
||||
'./**/*.{ts,tsx,vue,mts,json,yaml,md}': (stagedFiles: string[]) => {
|
||||
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
|
||||
|
||||
const hasBrowserTestsChanges = stagedFiles
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.3",
|
||||
"version": "1.43.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -44,7 +44,7 @@
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
46
packages/design-system/src/css/base.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Design System Base — Brand tokens + fonts only.
|
||||
* For marketing sites that don't use PrimeVue or the node editor.
|
||||
* Import the full style.css instead for the desktop app.
|
||||
*/
|
||||
|
||||
@import './fonts.css';
|
||||
|
||||
@theme {
|
||||
/* Font Families */
|
||||
--font-inter: 'Inter', sans-serif;
|
||||
|
||||
/* Palette Colors */
|
||||
--color-charcoal-100: #55565e;
|
||||
--color-charcoal-200: #494a50;
|
||||
--color-charcoal-300: #3c3d42;
|
||||
--color-charcoal-400: #313235;
|
||||
--color-charcoal-500: #2d2e32;
|
||||
--color-charcoal-600: #262729;
|
||||
--color-charcoal-700: #202121;
|
||||
--color-charcoal-800: #171718;
|
||||
|
||||
--color-neutral-550: #636363;
|
||||
|
||||
--color-ash-300: #bbbbbb;
|
||||
--color-ash-500: #828282;
|
||||
--color-ash-800: #444444;
|
||||
|
||||
--color-smoke-100: #f3f3f3;
|
||||
--color-smoke-200: #e9e9e9;
|
||||
--color-smoke-300: #e1e1e1;
|
||||
--color-smoke-400: #d9d9d9;
|
||||
--color-smoke-500: #c5c5c5;
|
||||
--color-smoke-600: #b4b4b4;
|
||||
--color-smoke-700: #a0a0a0;
|
||||
--color-smoke-800: #8a8a8a;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
/* Brand Colors */
|
||||
--color-electric-400: #f0ff41;
|
||||
--color-sapphire-700: #172dd7;
|
||||
--color-brand-yellow: var(--color-electric-400);
|
||||
--color-brand-blue: var(--color-sapphire-700);
|
||||
}
|
||||
@@ -25,6 +25,9 @@
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
|
||||