Compare commits
93 Commits
pablo_hack
...
test/prior
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de2a41cfb0 | ||
|
|
719d2d9b0b | ||
|
|
04c10981d2 | ||
|
|
82242f1b00 | ||
|
|
f9c334092c | ||
|
|
04aee0308b | ||
|
|
caa6f89436 | ||
|
|
c4d0b3c97a | ||
|
|
3eb7c29ea4 | ||
|
|
cc2cb7e89f | ||
|
|
d2f4d41960 | ||
|
|
070a5f59fe | ||
|
|
7864e780e7 | ||
|
|
db1257fdb3 | ||
|
|
7e7e2d5647 | ||
|
|
dabfc6521e | ||
|
|
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 | ||
|
|
98d56bdada | ||
|
|
b4bb6d9d13 | ||
|
|
6a9fb4e1d5 | ||
|
|
2838edbb53 | ||
|
|
657ae6a6c3 | ||
|
|
f37b0daa3a | ||
|
|
debbdfd1f7 | ||
|
|
9a5ee462f1 | ||
|
|
cff6a2255c | ||
|
|
860d049487 | ||
|
|
2d88720d80 | ||
|
|
818db0d49e | ||
|
|
cd45efa983 | ||
|
|
bd322314bc | ||
|
|
b33c8c2d30 |
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 }}
|
||||
|
||||
1
.github/workflows/release-version-bump.yaml
vendored
@@ -30,6 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
1
.github/workflows/weekly-docs-check.yaml
vendored
@@ -18,6 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
|
||||
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
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
407
browser_tests/assets/subgraphs/test-values-input-subgraph.json
Normal file
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
|
||||
"revision": 0,
|
||||
"last_node_id": 19,
|
||||
"last_link_id": 24,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 14,
|
||||
"type": "CLIPLoader",
|
||||
"pos": [143.16716182216328, 290.16372862874033],
|
||||
"size": [270, 117.3125],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [21]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPLoader"
|
||||
},
|
||||
"widgets_values": [null, "stable_diffusion", "default"]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1305.1455526601603, 472.17095792625025],
|
||||
"size": [225, 48],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 24
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"pos": [794.198171390827, 452.45433419677147],
|
||||
"size": [225, 172],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "renamed_clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 21
|
||||
},
|
||||
{
|
||||
"label": "renamed_seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"label": "renamed_vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 23
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [24]
|
||||
}
|
||||
],
|
||||
"title": "Input Test Subgraph",
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["12", "seed"],
|
||||
["15", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [155.04048166054417, 773.3816055422594],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [22]
|
||||
}
|
||||
],
|
||||
"title": "Seed Int",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "VAELoader",
|
||||
"pos": [163.6043676075426, 543.9624492717659],
|
||||
"size": [270, 82.65625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [23]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAELoader"
|
||||
},
|
||||
"widgets_values": ["pixel_space"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[21, 14, 0, 19, 0, "CLIP"],
|
||||
[22, 13, 0, 19, 1, "INT"],
|
||||
[23, 17, 0, 19, 2, "VAE"],
|
||||
[24, 19, 0, 18, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 19,
|
||||
"lastLinkId": 24,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Input Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
358.8694807105848, 439.23932667242485, 123.14453125,
|
||||
99.99999999999994
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [16],
|
||||
"localized_name": "clip",
|
||||
"label": "renamed_clip",
|
||||
"pos": [462.0140119605848, 459.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"localized_name": "seed",
|
||||
"label": "renamed_seed",
|
||||
"pos": [462.0140119605848, 479.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"linkIds": [19],
|
||||
"localized_name": "vae",
|
||||
"label": "renamed_vae",
|
||||
"pos": [462.0140119605848, 499.23932667242485]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [20],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1428.5510580294986, 483.2512895126797]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "KSampler",
|
||||
"pos": [769.2424728654022, 512.726159169824],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 17
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [18]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1208.5510580294986, 469.21581253470083],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 18
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 19
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [20]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [681.4596332342014, 243.17567172890932],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"label": "renamed_from_sidepanel",
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [17]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": 15,
|
||||
"origin_slot": 0,
|
||||
"target_id": 12,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"origin_id": 12,
|
||||
"origin_slot": 0,
|
||||
"target_id": 16,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 15,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 12,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 16,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"origin_id": 16,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6727925600199565,
|
||||
"offset": [446.69747171876463, 99.95078257277316]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
42
browser_tests/assets/widgets/image_compare_widget.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCompare",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "a_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "b_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCompare"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -168,3 +168,32 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,29 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/** The builder footer nav containing save/navigation buttons. */
|
||||
private get builderFooterNav(): Locator {
|
||||
return this.page
|
||||
.getByRole('button', { name: 'Exit app builder' })
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
/** Get a button in the builder footer by its accessible name. */
|
||||
getFooterButton(name: string | RegExp): Locator {
|
||||
return this.builderFooterNav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
/** Click the save/save-as button in the builder footer. */
|
||||
async clickSave() {
|
||||
await this.getFooterButton(/^Save/).first().click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** The "Opens as" popover tab above the builder footer. */
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
|
||||
147
browser_tests/fixtures/helpers/AssetsHelper.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private importedFiles: string[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
|
||||
this.generatedJobs = [...jobs]
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = [...this.generatedJobs]
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockOutputHistory([])
|
||||
await this.mockInputFiles([])
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
@@ -65,7 +79,8 @@ export const TestIds = {
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
@@ -76,6 +91,10 @@ export const TestIds = {
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -101,3 +120,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) {
|
||||
|
||||
118
browser_tests/helpers/builderTestUtils.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async function selectOutputNode(comfyPage: ComfyPage) {
|
||||
const { page } = comfyPage
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
|
||||
const { page } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
* Loads the default workflow, optionally transforms it (e.g. convert a node
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
await selectInputWidget(comfyPage, inputNode)
|
||||
|
||||
await appMode.goToOutputs()
|
||||
await selectOutputNode(comfyPage)
|
||||
|
||||
return inputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
})
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import { isSubgraph } from '../../src/utils/typeGuardUtil'
|
||||
|
||||
@@ -14,3 +16,30 @@ export function assertSubgraph(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the widget-input slot Y position and the node title height
|
||||
* for the promoted "text" input on the SubgraphNode.
|
||||
*
|
||||
* The slot Y should be at the widget row, not the header. A value near
|
||||
* zero or negative indicates the slot is positioned at the header (the bug).
|
||||
*/
|
||||
export function getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
|
||||
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input.widget || input.type !== 'STRING') continue
|
||||
return {
|
||||
hasPos: !!input.pos,
|
||||
posY: input.pos?.[1] ?? null,
|
||||
widgetName: input.widget.name,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
@@ -1,89 +1,11 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Convert the KSampler (id 3) in the default workflow to a subgraph,
|
||||
* enter builder, select the promoted seed widget as input and
|
||||
* SaveImage/PreviewImage as output.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
await appMode.goToOutputs()
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
import {
|
||||
saveAndReopenInAppMode,
|
||||
setupSubgraphBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
251
browser_tests/tests/builderSaveFlow.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
// The save-as dialog should appear with filename input and view type selection
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(dialog.getByRole('textbox')).toBeVisible()
|
||||
await expect(dialog.getByText('Save as')).toBeVisible()
|
||||
|
||||
// View type radio group should be present
|
||||
const radioGroup = dialog.getByRole('radiogroup')
|
||||
await expect(radioGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save as dialog allows entering filename and saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const workflowName = `${Date.now()} builder-save-test`
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill(workflowName)
|
||||
|
||||
// Save button should be enabled now
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeEnabled()
|
||||
await saveButton.click()
|
||||
|
||||
// Success dialog should appear
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
|
||||
test('Save as dialog disables save when filename is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Clear the filename input
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill('')
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Builder step navigation works correctly', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Should start at outputs (we ended there in setup)
|
||||
// Navigate to inputs
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Back button should be disabled on first step
|
||||
const backButton = appMode.getFooterButton('Back')
|
||||
await expect(backButton).toBeDisabled()
|
||||
|
||||
// Next button should be enabled
|
||||
const nextButton = appMode.getFooterButton('Next')
|
||||
await expect(nextButton).toBeEnabled()
|
||||
|
||||
// Navigate forward
|
||||
await appMode.next()
|
||||
|
||||
// Back button should now be enabled
|
||||
await expect(backButton).toBeEnabled()
|
||||
|
||||
// Navigate to preview (last step)
|
||||
await appMode.next()
|
||||
|
||||
// Next button should be disabled on last step
|
||||
await expect(nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Escape key exits builder mode', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Verify builder toolbar is visible
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
// Press Escape
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Builder toolbar should be gone
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
await appMode.exitBuilder()
|
||||
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button directly saves for previously saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-direct-save`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now click save again — should save directly
|
||||
await appMode.clickSave()
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Split button chevron opens save-as for saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-split-btn`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click the chevron dropdown trigger
|
||||
const chevronButton = appMode.getFooterButton('Save as')
|
||||
await chevronButton.click()
|
||||
|
||||
// "Save as" menu item should appear
|
||||
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
|
||||
await expect(menuItem).toBeVisible({ timeout: 5000 })
|
||||
await menuItem.click()
|
||||
|
||||
// Save-as dialog should appear
|
||||
const newSaveAsDialog = page.getByRole('dialog')
|
||||
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
// Without selecting any outputs, click the save button
|
||||
// It should trigger the connect-output popover
|
||||
await appMode.clickSave()
|
||||
|
||||
// The popover should show a message about connecting outputs
|
||||
await expect(
|
||||
page.getByText('Connect an output', { exact: false })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// App should be selected by default
|
||||
const appRadio = dialog.getByRole('radio', { name: /App/ })
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Click Node graph option
|
||||
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
66
browser_tests/tests/changeTrackerLoadGuard.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
test.describe(
|
||||
'Change Tracker - isLoadingGraph guard',
|
||||
{ tag: '@workflow' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Prevents checkState from corrupting workflow state during tab switch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Tab 0: default workflow (7 nodes)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// Save tab 0 so it has a unique name for tab switching
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||
|
||||
// Register an extension that forces checkState during graph loading.
|
||||
// This simulates the bug scenario where a user clicks during graph loading
|
||||
// which triggers a checkState call on the wrong graph, corrupting the activeState.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestCheckStateDuringLoad',
|
||||
afterConfigureGraph() {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore)
|
||||
.workflow.activeWorkflow
|
||||
if (!workflow) throw new Error('No workflow found')
|
||||
// Bypass the guard to reproduce the corruption bug:
|
||||
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
|
||||
|
||||
// Simulate the user clicking during graph loading
|
||||
workflow.changeTracker.checkState()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Create tab 1: blank workflow (0 nodes)
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to tab 0 (workflow-a).
|
||||
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// switch to blank tab and back to verify no corruption
|
||||
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
|
||||
await tab1.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// switch again and verify no corruption
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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 |
81
browser_tests/tests/imageCompare.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function createTestImageDataUrl(label: string, color: string): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">` +
|
||||
`<rect width="200" height="200" fill="${color}"/>` +
|
||||
`<text x="50%" y="50%" fill="white" font-size="24" ` +
|
||||
`text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
async function setImageCompareValue(
|
||||
comfyPage: ComfyPage,
|
||||
value: { beforeImages: string[]; afterImages: string[] }
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node).toContainText('No images to compare')
|
||||
await expect(node.locator('img')).toHaveCount(0)
|
||||
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(beforeImg).toBeVisible()
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
await expect(handle).toBeVisible()
|
||||
|
||||
expect(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
).toBe('50%')
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
||||
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
92
browser_tests/tests/maskEditor.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Mask Editor', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'opens mask editor from context menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-header')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await contextMenu.getByText('Open in Mask Editor').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
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 |
30
browser_tests/tests/sidebar/assets.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Assets sidebar', () => {
|
||||
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 and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
|
||||
await tab.importedTab.click()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).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
|
||||
|
||||
86
browser_tests/tests/subgraph-promoted-slot-position.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
}
|
||||
)
|
||||
57
browser_tests/tests/subgraph-promoted-widget-dom.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget DOM position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
const headerBox = await nodeLocator
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
|
||||
// Widget top should be below the header bottom
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
})
|
||||
}
|
||||
)
|
||||
117
browser_tests/tests/subgraphInputSlotRename.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph input slot rename propagation',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
const headerBox = await sgNode
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
const headerAfter = await sgNodeAfter
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetAfter = await seedWidgetAfter.boundingBox()
|
||||
expect(headerAfter).not.toBeNull()
|
||||
expect(widgetAfter).not.toBeNull()
|
||||
expect(widgetAfter!.y).toBeGreaterThan(
|
||||
headerAfter!.y + headerAfter!.height
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
114
browser_tests/tests/subgraphViewport.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 |