Compare commits
45 Commits
main
...
cloud/1.42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff8ff206e0 | ||
|
|
8312d2af4e | ||
|
|
7758099c4b | ||
|
|
b053786b03 | ||
|
|
8b67ac9b62 | ||
|
|
698a43cc0d | ||
|
|
35aced5c9f | ||
|
|
72a7dab9bb | ||
|
|
994dcbb7f5 | ||
|
|
2faff7b7d2 | ||
|
|
25204af108 | ||
|
|
7c54627215 | ||
|
|
0a65309dd2 | ||
|
|
01b6f5258c | ||
|
|
60dc9797e8 | ||
|
|
c789897e36 | ||
|
|
47876596b5 | ||
|
|
ffe067a9f8 | ||
|
|
988ca85ab3 | ||
|
|
a891593494 | ||
|
|
97619f2f69 | ||
|
|
e44a1ad782 | ||
|
|
7c92f4bd01 | ||
|
|
5fc5948d75 | ||
|
|
c3e98d548c | ||
|
|
4f715f3a23 | ||
|
|
812ef639ad | ||
|
|
e5fcca95a9 | ||
|
|
8de7795219 | ||
|
|
57a1d6cfa3 | ||
|
|
5718e584a9 | ||
|
|
25a353aa16 | ||
|
|
9f904b1e44 | ||
|
|
55710dfbba | ||
|
|
ed96ffba93 | ||
|
|
32801ccc21 | ||
|
|
cfadc35b18 | ||
|
|
6fc9d1c0e3 | ||
|
|
e11d5548a1 | ||
|
|
ccb3e33eb8 | ||
|
|
ed40779cdc | ||
|
|
d809f51831 | ||
|
|
a20258abd1 | ||
|
|
53458b5ada | ||
|
|
08b501b18f |
@@ -1,118 +0,0 @@
|
||||
---
|
||||
name: adr-compliance
|
||||
description: Checks code changes against Architecture Decision Records, with emphasis on ECS (ADR 0008) and command-pattern (ADR 0003) compliance
|
||||
severity-default: medium
|
||||
tools: [Read, Grep, glob]
|
||||
---
|
||||
|
||||
Check that code changes are consistent with the project's Architecture Decision Records in `docs/adr/`.
|
||||
|
||||
## Priority 1: ECS and Command-Pattern Compliance (ADR 0008 + ADR 0003)
|
||||
|
||||
These are the primary architectural guardrails. Every entity/litegraph change must be checked against them.
|
||||
|
||||
### Command Pattern (ADR 0003)
|
||||
|
||||
All entity state mutations MUST be expressible as **serializable, idempotent, deterministic commands**. This is required for CRDT sync, undo/redo, cross-environment portability, and gateway backends.
|
||||
|
||||
Flag:
|
||||
|
||||
- **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside of a store or command. All spatial data flows through `layoutStore` commands.
|
||||
- **Imperative fire-and-forget mutation** — Any new API that mutates entity state as a side effect rather than producing a serializable command object. Systems should produce command batches, not execute mutations directly.
|
||||
- **Void-returning mutation APIs** — New entity mutation functions that return `void` instead of a result type (`{ status: 'applied' | 'rejected' | 'no-op' }`). Commands need error/rejection semantics.
|
||||
- **Auto-incrementing IDs in new entity code** — New entity creation using auto-increment counters without acknowledging the CRDT collision problem. Concurrent environments need globally unique, stable identifiers.
|
||||
|
||||
### ECS Architecture (ADR 0008)
|
||||
|
||||
The graph domain model is migrating to ECS. New code must not make the migration harder.
|
||||
|
||||
Flag:
|
||||
|
||||
- **God-object growth** — New methods/properties added to `LGraphNode` (~4k lines), `LGraphCanvas` (~9k lines), `LGraph` (~3k lines), or `Subgraph`. Extract to systems, stores, or composables instead.
|
||||
- **Mixed data and behavior** — New component-like data structures that contain methods or back-references to parent entities. ECS components are plain data objects.
|
||||
- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes.
|
||||
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
|
||||
Flag:
|
||||
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
Entity API changes affect 40+ custom node repos. Changes to these patterns require an extension migration path.
|
||||
|
||||
Flag when changed without migration guidance:
|
||||
|
||||
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
|
||||
- `onConnectInput` / `onConnectOutput` validation hooks
|
||||
- `onWidgetChanged` handlers
|
||||
- `node.widgets.find(w => w.name === ...)` patterns
|
||||
- `node.serialize` overrides
|
||||
- `graph._version++` direct mutation
|
||||
- `getNodeById` usage patterns
|
||||
|
||||
## Priority 2: General ADR Compliance
|
||||
|
||||
For all other ADRs, iterate through each file in `docs/adr/` and extract the core lesson. Ensure changed code does not contradict accepted ADRs. Flag contradictions with proposed ADRs as directional guidance.
|
||||
|
||||
### How to Apply
|
||||
|
||||
1. Read `docs/adr/README.md` to get the full ADR index
|
||||
2. For each ADR, read the Decision and Consequences sections
|
||||
3. Check the diff against each ADR's constraints
|
||||
4. Only flag ACTUAL violations in changed code, not pre-existing patterns
|
||||
|
||||
### Skip List
|
||||
|
||||
These ADRs can be skipped for most reviews (they cover completed or narrow-scope decisions):
|
||||
|
||||
- **ADR 0004** (Rejected — Fork PrimeVue) — only relevant if someone proposes forking PrimeVue again
|
||||
|
||||
## How to Check
|
||||
|
||||
1. Identify changed files in the entity/litegraph layer: `src/lib/litegraph/`, `src/ecs/`, `src/platform/`, entity-related stores
|
||||
2. For Priority 1 patterns, use targeted searches:
|
||||
|
||||
```
|
||||
# Direct position mutation
|
||||
Grep: pattern="\.pos\s*=" path="src/lib/litegraph"
|
||||
Grep: pattern="\.size\s*=" path="src/lib/litegraph"
|
||||
|
||||
# God object growth (new methods)
|
||||
Grep: pattern="(class LGraphNode|class LGraphCanvas|class LGraph\b)" path="src/lib/litegraph"
|
||||
|
||||
# Version mutation
|
||||
Grep: pattern="_version\+\+" path="src/lib/litegraph"
|
||||
|
||||
# Extension callback changes
|
||||
Grep: pattern="on(ConnectionsChange|Removed|Added|Configure|ConnectInput|ConnectOutput|WidgetChanged)" path="src/lib/litegraph"
|
||||
```
|
||||
|
||||
3. For Priority 2, read `docs/adr/` files and check for contradictions
|
||||
|
||||
## Severity Guidelines
|
||||
|
||||
| Issue | Severity |
|
||||
| -------------------------------------------------------- | -------- |
|
||||
| Imperative mutation API without command-pattern wrapper | high |
|
||||
| New god-object method on LGraphNode/LGraphCanvas/LGraph | high |
|
||||
| Breaking extension callback without migration path | high |
|
||||
| New circular entity dependency | high |
|
||||
| Direct spatial mutation bypassing command pattern | medium |
|
||||
| Mixed data/behavior in component-like structures | medium |
|
||||
| New OOP inheritance pattern for entities | medium |
|
||||
| Contradicts accepted ADR direction | medium |
|
||||
| Contradicts proposed ADR direction without justification | low |
|
||||
|
||||
## Rules
|
||||
|
||||
- Only flag ACTUAL violations in changed code, not pre-existing patterns
|
||||
- If a change explicitly acknowledges an ADR tradeoff in comments or PR description, lower severity
|
||||
- Proposed ADRs carry less weight than accepted ones — flag as directional guidance
|
||||
- Reference the specific ADR number in every finding
|
||||
@@ -1,94 +0,0 @@
|
||||
# ADR Compliance Audit
|
||||
|
||||
Audit the current changes (or a specified PR) for compliance with Architecture Decision Records.
|
||||
|
||||
## Step 1: Gather the Diff
|
||||
|
||||
- If a PR number is provided, run: `gh pr diff $PR_NUMBER`
|
||||
- Otherwise, run: `git diff origin/main...HEAD` (or `git diff --cached` for staged changes)
|
||||
|
||||
## Step 2: Priority 1 — ECS and Command-Pattern Compliance
|
||||
|
||||
Read these documents for context:
|
||||
|
||||
```
|
||||
docs/adr/0003-crdt-based-layout-system.md
|
||||
docs/adr/0008-entity-component-system.md
|
||||
docs/architecture/ecs-target-architecture.md
|
||||
docs/architecture/ecs-migration-plan.md
|
||||
docs/architecture/appendix-critical-analysis.md
|
||||
```
|
||||
|
||||
### Check A: Command Pattern (ADR 0003)
|
||||
|
||||
Every entity state mutation must be a **serializable, idempotent, deterministic command** — replayable, undoable, transmittable over CRDT.
|
||||
|
||||
Flag:
|
||||
|
||||
1. **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside a store/command
|
||||
2. **Imperative fire-and-forget APIs** — Functions that mutate entity state as side effects rather than producing serializable command objects. Systems should produce command batches, not execute mutations directly.
|
||||
3. **Void-returning mutation APIs** — Entity mutations returning `void` instead of `{ status: 'applied' | 'rejected' | 'no-op' }`
|
||||
4. **Auto-increment IDs** — New entity creation via counters without addressing CRDT collision. Concurrent environments need globally unique identifiers.
|
||||
5. **Missing transaction semantics** — Multi-entity operations without atomic grouping (e.g., node removal = 10+ deletes with no rollback on failure)
|
||||
|
||||
### Check B: ECS Architecture (ADR 0008)
|
||||
|
||||
Flag:
|
||||
|
||||
1. **God-object growth** — New methods/properties on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
2. **Mixed data/behavior** — Component-like structures with methods or back-references
|
||||
3. **OOP instance patterns** — New `node.someProperty` or `node.someMethod()` for data that should be a World component
|
||||
4. **OOP inheritance** — New entity subclasses instead of component composition
|
||||
5. **Circular entity deps** — New `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas` circular imports
|
||||
6. **Direct `_version++`** — Mutating private version counter instead of through public API
|
||||
|
||||
### Check C: Extension Ecosystem Impact
|
||||
|
||||
If any of these patterns are changed, flag and require migration guidance:
|
||||
|
||||
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
|
||||
- `onConnectInput` / `onConnectOutput` validation hooks
|
||||
- `onWidgetChanged` handlers
|
||||
- `node.widgets.find(w => w.name === ...)` access patterns
|
||||
- `node.serialize` overrides
|
||||
- `graph._version++` direct mutation
|
||||
|
||||
Reference: 40+ custom node repos depend on these (rgthree-comfy, ComfyUI-Impact-Pack, cg-use-everywhere, etc.)
|
||||
|
||||
## Step 3: Priority 2 — General ADR Compliance
|
||||
|
||||
1. Read `docs/adr/README.md` for the full ADR index
|
||||
2. For each ADR (except skip list), read the Decision section
|
||||
3. Check the diff for contradictions
|
||||
4. Only flag ACTUAL violations in changed code
|
||||
|
||||
**Skip list**: ADR 0004 (Rejected — Fork PrimeVue)
|
||||
|
||||
## Step 4: Generate Report
|
||||
|
||||
```
|
||||
## ADR Compliance Audit Report
|
||||
|
||||
### Summary
|
||||
- Files audited: N
|
||||
- Priority 1 findings: N (command-pattern: N, ECS: N, ecosystem: N)
|
||||
- Priority 2 findings: N
|
||||
|
||||
### Priority 1: Command Pattern & ECS
|
||||
(List each with ADR reference, file, line, description)
|
||||
|
||||
### Priority 1: Extension Ecosystem Impact
|
||||
(List each changed callback/API with affected custom node repos)
|
||||
|
||||
### Priority 2: General ADR Compliance
|
||||
(List each with ADR reference, file, line, description)
|
||||
|
||||
### Compliant Patterns
|
||||
(Note changes that positively align with ADR direction)
|
||||
```
|
||||
|
||||
## Severity
|
||||
|
||||
- **Must fix**: Contradicts accepted ADR, or introduces imperative mutation API without command-pattern wrapper, or breaks extension callback without migration path
|
||||
- **Should discuss**: Contradicts proposed ADR direction — either align or propose ADR amendment
|
||||
- **Note**: Surfaces open architectural question not yet addressed by ADRs
|
||||
@@ -18,20 +18,12 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## CI Safety Rules
|
||||
|
||||
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
|
||||
|
||||
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
|
||||
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
|
||||
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
|
||||
| Item | Value |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
@@ -116,15 +108,11 @@ git fetch origin TARGET_BRANCH
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
### Never Admin-Merge Without CI
|
||||
|
||||
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
|
||||
@@ -19,44 +19,23 @@ done
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
# Check which got auto-PRs
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
|
||||
|
||||
## Step 2: Wait for CI & Merge Clean Auto-PRs
|
||||
|
||||
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
|
||||
```bash
|
||||
# Wait for CI to complete (~45 minutes for full suite)
|
||||
sleep 2700
|
||||
|
||||
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
|
||||
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
|
||||
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
|
||||
|
||||
# For PRs still open, check CI status
|
||||
for pr in $STILL_OPEN_PRS; do
|
||||
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
|
||||
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
|
||||
if [ "$CI_FAILED" != "0" ]; then
|
||||
# CI failed — collect details for triage
|
||||
echo "PR #$pr — CI FAILED:"
|
||||
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
|
||||
elif [ "$CI_PENDING" != "0" ]; then
|
||||
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
|
||||
else
|
||||
# All checks passed but didn't auto-merge (race condition or label issue)
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
fi
|
||||
for pr in $AUTO_PRS; do
|
||||
# Check size
|
||||
gh pr view $pr --json title,additions,deletions,changedFiles \
|
||||
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
|
||||
# Admin merge
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
@@ -84,13 +63,6 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
|
||||
# Wait for CI before merging — NEVER admin-merge without CI passing
|
||||
echo "Waiting for CI on PR #$NEW_PR..."
|
||||
gh pr checks $NEW_PR --watch --fail-fast || {
|
||||
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
|
||||
continue
|
||||
}
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
@@ -110,7 +82,7 @@ After completing all PRs in a wave for a target branch:
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
@@ -160,8 +132,7 @@ git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
@@ -175,31 +146,5 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
When CI fails on a backport PR, present failures to the user using this template:
|
||||
|
||||
```markdown
|
||||
### PR #XXXX — CI Failed
|
||||
|
||||
- **Failing check:** test / lint / typecheck
|
||||
- **Error:** (summary of the failure message)
|
||||
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
|
||||
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
|
||||
```
|
||||
|
||||
Common failure categories:
|
||||
|
||||
| Category | Example | Resolution |
|
||||
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
|
||||
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
|
||||
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
|
||||
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | CI Status | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
@@ -19,7 +19,6 @@ Track verification results per wave:
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Unit tests: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
@@ -42,11 +41,6 @@ Track verification results per wave:
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## CI Failure Report
|
||||
|
||||
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
|
||||
| --- | ------ | ------------- | ------------- | ----- | ---------- |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: contain-audit
|
||||
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
|
||||
---
|
||||
|
||||
# CSS Containment Audit
|
||||
|
||||
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Loads a large workflow (245 nodes) in a real browser
|
||||
2. Walks the DOM tree and scores every element as a containment candidate
|
||||
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
|
||||
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
|
||||
5. Takes before/after screenshots to detect visual breakage
|
||||
6. Generates a ranked report with actionable recommendations
|
||||
|
||||
## When to Use
|
||||
|
||||
- After adding new Vue components to the node rendering pipeline
|
||||
- When investigating rendering performance on large workflows
|
||||
- Before and after refactoring node DOM structure
|
||||
- As part of periodic performance audits
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Start the dev server first
|
||||
pnpm dev &
|
||||
|
||||
# Run the audit (uses the @audit tag, not included in normal CI runs)
|
||||
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
|
||||
|
||||
# View the HTML report
|
||||
pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
The audit outputs a table to the console:
|
||||
|
||||
```text
|
||||
CSS Containment Audit Results
|
||||
=======================================================
|
||||
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
|
||||
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
|
||||
2 | .node-body | 12 | 48 | -8% | -3% | OK
|
||||
3 | .node-header | 4 | 16 | +1% | 0% | OK
|
||||
```
|
||||
|
||||
- **Subtree**: Number of descendant elements (higher = more to skip)
|
||||
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
|
||||
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
|
||||
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
|
||||
|
||||
## Candidate Scoring
|
||||
|
||||
An element is a good containment candidate when:
|
||||
|
||||
1. **Large subtree** -- many descendants that the browser can skip recalculating
|
||||
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
|
||||
3. **No existing containment** -- `contain` is not already applied
|
||||
4. **Not a leaf** -- has at least a few child elements
|
||||
|
||||
Elements that should NOT get containment:
|
||||
|
||||
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
|
||||
- Elements whose height is determined by content and affects sibling layout
|
||||
- Very small subtrees (overhead of containment context outweighs benefit)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
|
||||
- Performance measurements have natural variance; run multiple times for confidence
|
||||
- Only tests idle and pan scenarios; widget interactions may differ
|
||||
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
|
||||
|
||||
## Example PR
|
||||
|
||||
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
|
||||
|
||||
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
|
||||
|
||||
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
|
||||
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
|
||||
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
|
||||
|
||||
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf tests | `browser_tests/tests/performance.spec.ts` |
|
||||
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
|
||||
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: layer-audit
|
||||
description: 'Detect violations of the layered architecture import rules (base -> platform -> workbench -> renderer). Runs ESLint with the import-x/no-restricted-paths rule and generates a grouped report.'
|
||||
---
|
||||
|
||||
# Layer Architecture Audit
|
||||
|
||||
Finds imports that violate the layered architecture boundary rules enforced by `import-x/no-restricted-paths` in `eslint.config.ts`.
|
||||
|
||||
## Layer Hierarchy (bottom to top)
|
||||
|
||||
```
|
||||
renderer (top -- can import from all lower layers)
|
||||
^
|
||||
workbench
|
||||
^
|
||||
platform
|
||||
^
|
||||
base (bottom -- cannot import from any upper layer)
|
||||
```
|
||||
|
||||
Each layer may only import from layers below it.
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Run ESLint filtering for just the layer boundary rule violations
|
||||
pnpm lint 2>&1 | grep 'import-x/no-restricted-paths' -B1 | head -200
|
||||
```
|
||||
|
||||
To get a full structured report, run:
|
||||
|
||||
```bash
|
||||
# Collect all violations from base/, platform/, workbench/ layers
|
||||
pnpm eslint src/base/ src/platform/ src/workbench/ --no-error-on-unmatched-pattern --rule '{"import-x/no-restricted-paths": "warn"}' --format compact 2>&1 | grep 'no-restricted-paths' | sort
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
Each violation line shows:
|
||||
|
||||
- The **file** containing the bad import
|
||||
- The **import path** crossing the boundary
|
||||
- The **message** identifying which layer pair is violated
|
||||
|
||||
### Grouping by Layer Pair
|
||||
|
||||
After collecting violations, group them by the layer pair pattern:
|
||||
|
||||
| Layer pair | Meaning |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| base -> platform | base/ importing from platform/ |
|
||||
| base -> workbench | base/ importing from workbench/ |
|
||||
| base -> renderer | base/ importing from renderer/ |
|
||||
| platform -> workbench | platform/ importing from workbench/ |
|
||||
| platform -> renderer | platform/ importing from renderer/ |
|
||||
| workbench -> renderer | workbench/ importing from renderer/ |
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before creating a PR that adds imports between `src/base/`, `src/platform/`, `src/workbench/`, or `src/renderer/`
|
||||
- When auditing the codebase to find and plan migration of existing violations
|
||||
- After moving files between layers to verify no new violations were introduced
|
||||
|
||||
## Fixing Violations
|
||||
|
||||
Common strategies to resolve a layer violation:
|
||||
|
||||
1. **Move the import target down** -- if the imported module doesn't depend on upper-layer concepts, move it to a lower layer
|
||||
2. **Introduce an interface** -- define an interface/type in the lower layer and implement it in the upper layer via dependency injection or a registration pattern
|
||||
3. **Move the importing file up** -- if the file logically belongs in a higher layer, relocate it
|
||||
4. **Extract shared logic** -- pull the shared functionality into `base/` or a shared utility
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ------------------------------- | ------------------ |
|
||||
| ESLint config (rule definition) | `eslint.config.ts` |
|
||||
| Base layer | `src/base/` |
|
||||
| Platform layer | `src/platform/` |
|
||||
| Workbench layer | `src/workbench/` |
|
||||
| Renderer layer | `src/renderer/` |
|
||||
@@ -44,18 +44,15 @@ await expect(node).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
|
||||
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
| `subtree intercepts pointer events` | Canvas overlay (z-999) | Use `dispatchEvent` on the DOM element to bypass overlay |
|
||||
| Context menu empty / wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
|
||||
| `navigateIntoSubgraph` timeout | Node too small in asset | Use node size `[400, 200]` minimum in test asset JSON |
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
|
||||
## Test Tags
|
||||
|
||||
|
||||
@@ -28,21 +28,3 @@ reviews:
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
|
||||
|
||||
This check applies ONLY when the PR modifies files under `src/lib/litegraph/`, `src/ecs/`, or files related to graph entities (nodes, links, widgets, slots, reroutes, groups, subgraphs).
|
||||
|
||||
If none of those paths appear in the changed files, pass immediately.
|
||||
|
||||
When applicable, check for:
|
||||
1. **Command pattern (ADR 0003)**: Entity state mutations must be serializable, idempotent, deterministic commands — not imperative fire-and-forget side effects. Flag direct spatial mutation (`node.pos =`, `node.size =`, `group.pos =`) outside of a store or command, and any new void-returning mutation API that should produce a command object.
|
||||
2. **God-object growth (ADR 0008)**: New methods/properties added to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` that add responsibilities rather than extracting/migrating existing ones.
|
||||
3. **ECS data/behavior separation (ADR 0008)**: Component-like data structures that contain methods or back-references to parent entities. ECS components must be plain data. New OOP instance patterns (`node.someProperty`, `node.someMethod()`) for data that should be a World component.
|
||||
4. **Extension ecosystem (ADR 0008)**: Changes to extension-facing callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure`, `onConnectInput/Output`, `onWidgetChanged`), `node.widgets` access, `node.serialize` overrides, or `graph._version++` without migration guidance. These affect 40+ custom node repos.
|
||||
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
@@ -45,4 +45,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
# SENTRY_PROJECT=cloud-frontend-staging
|
||||
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
|
||||
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -12,7 +12,7 @@ runs:
|
||||
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
1
.github/workflows/ci-perf-report.yaml
vendored
@@ -64,7 +64,6 @@ jobs:
|
||||
mkdir -p temp/perf-meta
|
||||
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
|
||||
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
|
||||
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
7
.github/workflows/ci-size-data.yaml
vendored
@@ -8,10 +8,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: size-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -32,12 +28,11 @@ jobs:
|
||||
- name: Collect size data
|
||||
run: node scripts/size-collect.js
|
||||
|
||||
- name: Save PR metadata
|
||||
- name: Save PR number & base branch
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
echo ${{ github.event.number }} > ./temp/size/number.txt
|
||||
echo ${{ github.base_ref }} > ./temp/size/base.txt
|
||||
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
|
||||
|
||||
- name: Upload size data
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
6
.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.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
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.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/pr-backport.yaml
vendored
@@ -348,8 +348,6 @@ jobs:
|
||||
PR_NUM=$(echo "${PR_URL}" | grep -o '[0-9]*$')
|
||||
|
||||
if [ -n "${PR_NUM}" ]; then
|
||||
gh pr merge "${PR_NUM}" --auto --squash --repo "${{ github.repository }}" \
|
||||
|| echo "::warning::Failed to enable auto-merge for PR #${PR_NUM}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}"
|
||||
fi
|
||||
else
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
148
.github/workflows/pr-perf-report.yaml
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
name: 'PR: Performance Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Performance Report']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download PR metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-meta
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/perf-meta/
|
||||
|
||||
- name: Resolve and validate PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const artifactPr = Number(fs.readFileSync('temp/perf-meta/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/perf-meta/base.txt', 'utf8').trim();
|
||||
|
||||
// Resolve PR from trusted workflow context
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.setFailed('Unable to resolve PR from workflow_run context.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(pr.number) !== artifactPr) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedBase = pr.base?.ref;
|
||||
if (!trustedBase || artifactBase !== trustedBase) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', trustedBase);
|
||||
|
||||
- name: Check if results are still current
|
||||
id: sha-check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const runSha = context.payload.workflow_run.head_sha;
|
||||
const currentSha = pr.head.sha;
|
||||
if (runSha !== currentSha) {
|
||||
core.info(`Skipping stale report: run SHA ${runSha} != current PR SHA ${currentSha}`);
|
||||
core.setOutput('stale', 'true');
|
||||
} else {
|
||||
core.setOutput('stale', 'false');
|
||||
}
|
||||
|
||||
- name: Download PR perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-metrics
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: test-results/
|
||||
|
||||
- name: Download baseline perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Load historical baselines from perf-data branch
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p temp/perf-history
|
||||
|
||||
git fetch origin perf-data 2>/dev/null || {
|
||||
echo "perf-data branch not found, skipping historical data"
|
||||
exit 0
|
||||
}
|
||||
|
||||
INDEX=0
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
|
||||
DIR="temp/perf-history/$INDEX"
|
||||
mkdir -p "$DIR"
|
||||
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
|
||||
INDEX=$((INDEX + 1))
|
||||
done
|
||||
|
||||
echo "Loaded $INDEX historical baselines"
|
||||
|
||||
- name: Generate perf report
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./perf-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PERF -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
233
.github/workflows/pr-report.yaml
vendored
@@ -1,233 +0,0 @@
|
||||
name: 'PR: Unified Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: pr-report-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the workflow_run head SHA matches the current PR head
|
||||
const { data: livePr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
|
||||
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', livePr.base.ref);
|
||||
core.setOutput('head-sha', livePr.head.sha);
|
||||
|
||||
- name: Find size workflow run for this commit
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-size
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci-size-data.yaml',
|
||||
head_sha: headSha,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
|
||||
- name: Find perf workflow run for this commit
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-perf
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci-perf-report.yaml',
|
||||
head_sha: headSha,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
|
||||
- name: Download size data (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: size-data
|
||||
run_id: ${{ steps.find-size.outputs.run-id }}
|
||||
path: temp/size
|
||||
|
||||
- name: Download size baseline
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-size-data.yaml
|
||||
event: push
|
||||
name: size-data
|
||||
path: temp/size-prev
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf metrics (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-metrics
|
||||
run_id: ${{ steps.find-perf.outputs.run-id }}
|
||||
path: test-results/
|
||||
|
||||
- name: Download perf baseline
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf history from perf-data branch
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
|
||||
git fetch origin perf-data --depth=1
|
||||
mkdir -p temp/perf-history
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
|
||||
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"
|
||||
fi
|
||||
|
||||
- name: Generate unified report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: >
|
||||
node scripts/unified-report.js
|
||||
--size-status=${{ steps.find-size.outputs.status }}
|
||||
--perf-status=${{ steps.find-perf.outputs.status }}
|
||||
> pr-report.md
|
||||
|
||||
- name: Remove legacy separate comments
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
|
||||
const legacyMarkers = [
|
||||
'<!-- COMFYUI_FRONTEND_SIZE -->',
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const comment of comments) {
|
||||
if (legacyMarkers.some(m => comment.body?.includes(m))) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
core.info(`Deleted legacy comment ${comment.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./pr-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
24
.github/workflows/pr-request-team-review.yaml
vendored
@@ -1,24 +0,0 @@
|
||||
# Request team review for PRs from external contributors.
|
||||
name: PR:Request Team Review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
request-review:
|
||||
if: >-
|
||||
!contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'),
|
||||
github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Request team review
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr edit ${{ github.event.pull_request.number }} \
|
||||
--repo ${{ github.repository }} \
|
||||
--add-reviewer Comfy-org/comfy_frontend_devs
|
||||
133
.github/workflows/pr-size-report.yaml
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
name: 'PR: Size Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data']
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to report on'
|
||||
required: true
|
||||
type: number
|
||||
run_id:
|
||||
description: 'Size data workflow run ID'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success') ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download size data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: size-data
|
||||
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
|
||||
path: temp/size
|
||||
|
||||
- name: Resolve and validate PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// workflow_dispatch: validate artifact metadata against API-resolved PR
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
const pullNumber = Number('${{ inputs.pr_number }}');
|
||||
const { data: dispatchPr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
|
||||
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
|
||||
|
||||
if (artifactPr !== dispatchPr.number) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match dispatch PR (${dispatchPr.number}).`);
|
||||
return;
|
||||
}
|
||||
if (artifactBase !== dispatchPr.base.ref) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match dispatch PR base (${dispatchPr.base.ref}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(dispatchPr.number));
|
||||
core.setOutput('base', dispatchPr.base.ref);
|
||||
return;
|
||||
}
|
||||
|
||||
// workflow_run: validate artifact metadata against trusted context
|
||||
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
|
||||
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.setFailed('Unable to resolve PR from workflow_run context.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(pr.number) !== artifactPr) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedBase = pr.base?.ref;
|
||||
if (!trustedBase || artifactBase !== trustedBase) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', trustedBase);
|
||||
|
||||
- name: Download previous size data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-size-data.yaml
|
||||
event: push
|
||||
name: size-data
|
||||
path: temp/size-prev
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Generate size report
|
||||
run: node scripts/size-report.js > size-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./size-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_SIZE -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
142
.github/workflows/release-biweekly-comfyui.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
path: comfyui
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -162,132 +162,9 @@ jobs:
|
||||
echo "- Target version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
publish-pypi:
|
||||
needs: [resolve-version, trigger-release-if-needed]
|
||||
if: >
|
||||
always() &&
|
||||
needs.resolve-version.result == 'success' &&
|
||||
(needs.trigger-release-if-needed.result == 'success' ||
|
||||
needs.trigger-release-if-needed.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for release PR to be created and merged
|
||||
if: needs.trigger-release-if-needed.result == 'success'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
TARGET_BRANCH="${{ needs.resolve-version.outputs.target_branch }}"
|
||||
echo "Waiting for version bump PR for v${TARGET_VERSION} on ${TARGET_BRANCH} to be merged..."
|
||||
|
||||
# Poll for up to 30 minutes (a human or automation needs to merge the version bump PR)
|
||||
for i in $(seq 1 60); do
|
||||
# Check if the tag exists (release-draft-create creates a tag on merge)
|
||||
if gh api "repos/Comfy-Org/ComfyUI_frontend/git/ref/tags/v${TARGET_VERSION}" --silent 2>/dev/null; then
|
||||
echo "✅ Tag v${TARGET_VERSION} found — release PR has been merged"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/60: Tag v${TARGET_VERSION} not found yet, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "❌ Timed out waiting for tag v${TARGET_VERSION}"
|
||||
exit 1
|
||||
|
||||
- name: Checkout code at target version
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: v${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Build project
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
ENABLE_MINIFY: 'true'
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
|
||||
- name: Build and publish PyPI package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
- name: Wait for PyPI propagation
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
PACKAGE="comfyui-frontend-package"
|
||||
echo "Waiting for ${PACKAGE}==${TARGET_VERSION} to be available on PyPI..."
|
||||
|
||||
# Wait up to 15 minutes (polling every 30 seconds)
|
||||
for i in $(seq 1 30); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ ${PACKAGE}==${TARGET_VERSION} is available on PyPI"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/30: PyPI returned HTTP ${HTTP_CODE}, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "❌ Timed out waiting for ${PACKAGE}==${TARGET_VERSION} on PyPI"
|
||||
exit 1
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## PyPI Publishing" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Package: comfyui-frontend-package" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Status: ✅ Published and confirmed available" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
create-comfyui-pr:
|
||||
needs:
|
||||
[
|
||||
check-release-week,
|
||||
resolve-version,
|
||||
trigger-release-if-needed,
|
||||
publish-pypi
|
||||
]
|
||||
if: always() && needs.resolve-version.result == 'success' && needs.publish-pypi.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
|
||||
needs: [check-release-week, resolve-version, trigger-release-if-needed]
|
||||
if: always() && needs.resolve-version.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -359,8 +236,11 @@ jobs:
|
||||
EOF
|
||||
)
|
||||
|
||||
PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
|
||||
BODY=$''"${PYPI_NOTE}"$'\n\n'"${BODY}"
|
||||
# Add release PR note if release was triggered
|
||||
if [ "${{ needs.resolve-version.outputs.needs_release }}" = "true" ]; then
|
||||
RELEASE_NOTE="⚠️ **Release PR must be merged first** - check [release workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)"
|
||||
BODY=$''"${RELEASE_NOTE}"$'\n\n'"${BODY}"
|
||||
fi
|
||||
|
||||
# Save to file for later use
|
||||
printf '%s\n' "$BODY" > pr-body.txt
|
||||
@@ -427,11 +307,7 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
||||
echo "PR already exists (#${EXISTING_PR}), refreshing title/body"
|
||||
gh pr edit "$EXISTING_PR" \
|
||||
--repo Comfy-Org/ComfyUI \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
--body-file ../pr-body.txt
|
||||
echo "PR already exists (#${EXISTING_PR}), updating branch will update the PR"
|
||||
else
|
||||
echo "Failed to create PR and no existing PR found"
|
||||
exit 1
|
||||
|
||||
34
.github/workflows/release-draft-create.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -99,6 +99,37 @@ jobs:
|
||||
${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
|
||||
publish_pypi:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
- name: Setup pypi package
|
||||
run: |
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
publish_types:
|
||||
needs: build
|
||||
uses: ./.github/workflows/release-npm-types.yaml
|
||||
@@ -111,6 +142,7 @@ jobs:
|
||||
name: Comment Release Summary
|
||||
needs:
|
||||
- draft_release
|
||||
- publish_pypi
|
||||
- publish_types
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
2
.github/workflows/release-pypi-dev.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
3
.github/workflows/release-version-bump.yaml
vendored
@@ -30,7 +30,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -143,7 +142,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
3
.github/workflows/weekly-docs-check.yaml
vendored
@@ -18,7 +18,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -29,7 +28,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
|
||||
14
AGENTS.md
@@ -208,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
3. Keep your module mocks contained
|
||||
Do not use global mutable state within the test file
|
||||
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
|
||||
4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
@@ -231,18 +231,6 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
All architectural decisions are documented in `docs/adr/`. Code changes must be consistent with accepted ADRs. Proposed ADRs indicate design direction and should be treated as guidance. See `.agents/checks/adr-compliance.md` for automated validation rules.
|
||||
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
|
||||
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
- Follow good software engineering principles
|
||||
|
||||
57
CODEOWNERS
@@ -1,58 +1,61 @@
|
||||
# Global Ownership
|
||||
* @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
# Translations
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
|
||||
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
|
||||
|
||||
/** State of a maintenance task, managed by the maintenance task store. */
|
||||
class MaintenanceTaskRunner {
|
||||
export class MaintenanceTaskRunner {
|
||||
constructor(readonly task: MaintenanceTask) {}
|
||||
|
||||
private _state?: MaintenanceTaskState
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
|
||||
2
apps/website/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
dist/
|
||||
.astro/
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/website/src/env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="astro/client" />
|
||||
@@ -1,2 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
}
|
||||
@@ -30,14 +30,6 @@ browser_tests/
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| -------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `subtree intercepts pointer events` on DOM widgets | Canvas `z-999` overlay intercepts `click()` | Use Playwright's `locator.dispatchEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })` |
|
||||
| Context menu empty or wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
|
||||
| `navigateIntoSubgraph` timeout | Node too small in test asset JSON | Use node size `[400, 200]` minimum |
|
||||
|
||||
## After Making Changes
|
||||
|
||||
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
|
||||
|
||||
@@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example
|
||||
**Always use UI mode for development:**
|
||||
|
||||
```bash
|
||||
pnpm test:browser:local --ui
|
||||
pnpm exec playwright test --ui
|
||||
```
|
||||
|
||||
UI mode features:
|
||||
@@ -91,8 +91,29 @@ UI mode features:
|
||||
For CI or headless testing:
|
||||
|
||||
```bash
|
||||
pnpm test:browser:local # Run all tests
|
||||
pnpm test:browser:local widget.spec.ts # Run specific test file
|
||||
pnpm exec playwright test # Run all tests
|
||||
pnpm exec playwright test widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
### Local Development Config
|
||||
|
||||
For debugging, you can try adjusting these settings in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
// VERY HELPFUL: Skip screenshot tests locally
|
||||
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
|
||||
|
||||
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
|
||||
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
|
||||
timeout: 30000, // Longer timeout for breakpoints
|
||||
|
||||
use: {
|
||||
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
|
||||
video: 'on' // Always record video (CI uses 'retain-on-failure')
|
||||
},
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
@@ -364,7 +385,7 @@ export default defineConfig({
|
||||
Option 2 - Generate local baselines for comparison:
|
||||
|
||||
```bash
|
||||
pnpm test:browser:local --update-snapshots
|
||||
pnpm exec playwright test --update-snapshots
|
||||
```
|
||||
|
||||
### Creating New Screenshot Baselines
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
{
|
||||
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"pos": [400, 300],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 120,
|
||||
"lastLinkId": 276,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Slot Drift Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [0, 300, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 300, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 120,
|
||||
"type": "ComfySwitchNode",
|
||||
"title": "Switch (CFG)",
|
||||
"pos": [100, 100],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [257, 271, 276]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "ComfySwitchNode" },
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 50],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 276 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 350],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 271 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 257,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 271,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 86,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 276,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] },
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [50, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
Page
|
||||
} from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
@@ -19,19 +19,15 @@ 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'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
@@ -42,7 +38,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
@@ -57,7 +53,6 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
@@ -81,11 +76,6 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
}
|
||||
|
||||
get workflowsTab() {
|
||||
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
|
||||
return this._workflowsTab
|
||||
@@ -196,12 +186,9 @@ export class ComfyPage {
|
||||
public readonly contextMenu: ContextMenu
|
||||
public readonly toast: ToastHelper
|
||||
public readonly dragDrop: DragDropHelper
|
||||
public readonly featureFlags: FeatureFlagHelper
|
||||
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 */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -238,17 +225,14 @@ export class ComfyPage {
|
||||
this.nodeOps = new NodeOperationsHelper(this)
|
||||
this.settings = new SettingsHelper(page)
|
||||
this.keyboard = new KeyboardHelper(page, this.canvas)
|
||||
this.clipboard = new ClipboardHelper(this.keyboard, page)
|
||||
this.clipboard = new ClipboardHelper(this.keyboard)
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
@@ -300,7 +284,9 @@ export class ComfyPage {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
} = {}) {
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
await this.goto()
|
||||
|
||||
// Mock release endpoint to prevent changelog popups
|
||||
if (mockReleases) {
|
||||
await this.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
@@ -320,16 +306,12 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
if (clearStorage) {
|
||||
// Navigate to a lightweight same-origin endpoint to obtain a page
|
||||
// context for clearing storage without loading the full frontend app.
|
||||
await this.page.goto(`${this.url}/api/users`)
|
||||
await this.page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
@@ -462,13 +444,12 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const needsPerf =
|
||||
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')
|
||||
if (needsPerf) await comfyPage.perf.init()
|
||||
const isPerf = testInfo.tags.includes('@perf')
|
||||
if (isPerf) await comfyPage.perf.init()
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
if (isPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
const comfyMouse = new ComfyMouse(comfyPage)
|
||||
|
||||
@@ -168,32 +168,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,41 +68,6 @@ export class AppModeHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject linearData into the current graph and enter app mode.
|
||||
*
|
||||
* Serializes the graph, injects linearData with the given inputs and
|
||||
* auto-detected output node IDs, then reloads so the appModeStore
|
||||
* picks up the data via its activeWorkflow watcher.
|
||||
*
|
||||
* @param inputs - Widget selections as [nodeId, widgetName] tuples
|
||||
*/
|
||||
async enterAppModeWithInputs(inputs: [string, string][]) {
|
||||
await this.page.evaluate(async (inputTuples) => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
}, inputs)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -160,42 +125,4 @@ export class AppModeHelper {
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private importedFiles: string[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
|
||||
this.generatedJobs = [...jobs]
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = [...this.generatedJobs]
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockOutputHistory([])
|
||||
await this.mockInputFiles([])
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,12 +91,6 @@ 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
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { KeyboardHelper } from './KeyboardHelper'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
private readonly page: Page
|
||||
) {}
|
||||
constructor(private readonly keyboard: KeyboardHelper) {}
|
||||
|
||||
async copy(locator?: Locator | null): Promise<void> {
|
||||
await this.keyboard.ctrlSend('KeyC', locator ?? null)
|
||||
@@ -19,44 +12,4 @@ export class ClipboardHelper {
|
||||
async paste(locator?: Locator | null): Promise<void> {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync } from 'fs'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(
|
||||
@@ -25,15 +24,13 @@ export class DragDropHelper {
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
waitForUpload = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
@@ -45,15 +42,25 @@ export class DragDropHelper {
|
||||
fileType?: string
|
||||
buffer?: Uint8Array | number[]
|
||||
url?: string
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
} = { dropPosition }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
|
||||
const getFileType = (fileName: string) => {
|
||||
if (fileName.endsWith('.png')) return 'image/png'
|
||||
if (fileName.endsWith('.svg')) return 'image/svg+xml'
|
||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||
if (fileName.endsWith('.avif')) return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileType = getFileType(fileName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
@@ -118,17 +125,15 @@ export class DragDropHelper {
|
||||
)
|
||||
}
|
||||
|
||||
if (!params.preserveNativePropagation) {
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
}
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
targetElement.dispatchEvent(dragOverEvent)
|
||||
targetElement.dispatchEvent(dropEvent)
|
||||
@@ -159,10 +164,7 @@ export class DragDropHelper {
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
dropPosition?: Position
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
options: { dropPosition?: Position } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class FeatureFlagHelper {
|
||||
private featuresRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Seed feature flags via `addInitScript` so they are available in
|
||||
* localStorage before the app JS executes on first load.
|
||||
* Must be called before `comfyPage.setup()` / `page.goto()`.
|
||||
*
|
||||
* Note: Playwright init scripts persist for the page lifetime and
|
||||
* cannot be removed. Call this once per test, before navigation.
|
||||
*/
|
||||
async seedFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.addInitScript((flagMap: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(flagMap)) {
|
||||
localStorage.setItem(`ff:${key}`, JSON.stringify(value))
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set feature flags at runtime via localStorage. Uses the `ff:` prefix
|
||||
* that devFeatureFlagOverride.ts reads in dev mode.
|
||||
* For flags needed before page init, use `seedFlags()` instead.
|
||||
*/
|
||||
async setFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(flagMap)) {
|
||||
localStorage.setItem(`ff:${key}`, JSON.stringify(value))
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setFlags({ [name]: value })
|
||||
}
|
||||
|
||||
async clearFlags(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith('ff:')) keysToRemove.push(key)
|
||||
}
|
||||
keysToRemove.forEach((k) => {
|
||||
localStorage.removeItem(k)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
async mockServerFeatures(features: Record<string, unknown>): Promise<void> {
|
||||
this.featuresRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(features)
|
||||
})
|
||||
await this.page.route('**/api/features', this.featuresRouteHandler)
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.featuresRouteHandler) {
|
||||
await this.page.unroute('**/api/features', this.featuresRouteHandler)
|
||||
this.featuresRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ export interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -191,7 +190,6 @@ export class PerformanceHelper {
|
||||
layoutDurationMs: delta('LayoutDuration') * 1000,
|
||||
taskDurationMs: delta('TaskDuration') * 1000,
|
||||
heapDeltaBytes: delta('JSHeapUsedSize'),
|
||||
heapUsedBytes: after.JSHeapUsedSize,
|
||||
domNodes: delta('Nodes'),
|
||||
jsHeapTotalBytes: delta('JSHeapTotalSize'),
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Mock the /api/queue endpoint to return specific queue state.
|
||||
*/
|
||||
async mockQueueState(
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
queue_running: Array.from({ length: running }, (_, i) => [
|
||||
i,
|
||||
`running-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
]),
|
||||
queue_pending: Array.from({ length: pending }, (_, i) => [
|
||||
i,
|
||||
`pending-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
])
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the /api/history endpoint with completed/failed job entries.
|
||||
*/
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
prompt: [0, job.promptId, {}, {}, []],
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: job.status === 'success' ? 'success' : 'error',
|
||||
completed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
this.historyRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all route mocks set by this helper.
|
||||
*/
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.queueRouteHandler) {
|
||||
await this.page.unroute('**/api/queue', this.queueRouteHandler)
|
||||
this.queueRouteHandler = null
|
||||
}
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export function getMimeType(fileName: string): string {
|
||||
const name = fileName.toLowerCase()
|
||||
if (name.endsWith('.png')) return 'image/png'
|
||||
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg'
|
||||
if (name.endsWith('.webp')) return 'image/webp'
|
||||
if (name.endsWith('.svg')) return 'image/svg+xml'
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
@@ -20,33 +20,16 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
zoomOutAction: 'zoom-out-action',
|
||||
zoomToFitAction: 'zoom-to-fit-action',
|
||||
zoomPercentageInput: 'zoom-percentage-input'
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
|
||||
},
|
||||
dialogs: {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
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',
|
||||
missingNodes: 'missing-nodes-warning',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
whatsNewSection: 'whats-new-section'
|
||||
},
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
@@ -78,7 +61,6 @@ export const TestIds = {
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
@@ -90,10 +72,6 @@ export const TestIds = {
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -105,7 +83,6 @@ export type TestIdValue =
|
||||
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
|
||||
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
|
||||
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
|
||||
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
|
||||
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
@@ -119,4 +96,3 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -281,14 +281,6 @@ export class NodeReference {
|
||||
getType(): Promise<string> {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async centerOnNode(): Promise<void> {
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found`)
|
||||
window.app!.canvas.centerOnNode(node)
|
||||
}, this.id)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
|
||||
@@ -29,8 +29,7 @@ export const webSocketFixture = base.extend<{
|
||||
function ([data, url]) {
|
||||
if (!url) {
|
||||
// If no URL specified, use page URL
|
||||
const u = new URL(window.location.href)
|
||||
u.hash = ''
|
||||
const u = new URL(window.location.toString())
|
||||
u.protocol = 'ws:'
|
||||
u.pathname = '/'
|
||||
url = u.toString() + 'ws'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { backupPath } from './utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
export default function globalSetup() {
|
||||
export default function globalSetup(_config: FullConfig) {
|
||||
if (!process.env.CI) {
|
||||
if (process.env.TEST_COMFYUI_DIR) {
|
||||
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
export default function globalTeardown() {
|
||||
export default function globalTeardown(_config: FullConfig) {
|
||||
writePerfReport()
|
||||
|
||||
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
* All widgets from the default graph are selected so the panel scrolls,
|
||||
* pushing the last widget's dropdown to the clipping boundary.
|
||||
*/
|
||||
const DEFAULT_INPUTS: [string, string][] = [
|
||||
['4', 'ckpt_name'],
|
||||
['6', 'text'],
|
||||
['7', 'text'],
|
||||
['5', 'width'],
|
||||
['5', 'height'],
|
||||
['5', 'batch_size'],
|
||||
['3', 'seed'],
|
||||
['3', 'steps'],
|
||||
['3', 'cfg'],
|
||||
['3', 'sampler_name'],
|
||||
['3', 'scheduler'],
|
||||
['3', 'denoise'],
|
||||
['9', 'filename_prefix']
|
||||
]
|
||||
|
||||
function isClippedByAnyAncestor(el: Element): boolean {
|
||||
const child = el.getBoundingClientRect()
|
||||
let parent = el.parentElement
|
||||
|
||||
while (parent) {
|
||||
const overflow = getComputedStyle(parent).overflow
|
||||
if (overflow !== 'visible') {
|
||||
const p = parent.getBoundingClientRect()
|
||||
if (
|
||||
child.top < p.top ||
|
||||
child.bottom > p.bottom ||
|
||||
child.left < p.left ||
|
||||
child.right > p.right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Add a node to the graph by type and return its ID. */
|
||||
async function addNode(page: Page, nodeType: string): Promise<string> {
|
||||
return page.evaluate((type) => {
|
||||
const node = window.app!.graph.add(
|
||||
window.LiteGraph!.createNode(type, undefined, {})
|
||||
)
|
||||
return String(node!.id)
|
||||
}, nodeType)
|
||||
}
|
||||
|
||||
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Select dropdown is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[saveVideoId, 'codec']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the codec widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
|
||||
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
||||
await codecSelect.click()
|
||||
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
|
||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[loadImageId, 'image']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the image widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
const imageRow = widgetList.locator(
|
||||
'div:has(> div > span:text-is("image"))'
|
||||
)
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -30,18 +30,10 @@ async function setupSubgraphBuilder(comfyPage: 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 page.mouse.click(seedPos.x, seedPos.y)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
@@ -56,15 +48,9 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
)
|
||||
)
|
||||
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
|
||||
)
|
||||
const saveImagePos = await saveImageRef.getPosition()
|
||||
// Click left edge — the right side is hidden by the builder panel
|
||||
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
@@ -94,15 +80,9 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -111,11 +91,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
await appMode.renameWidget(menu, 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
const workflowName = `${new Date().getTime()} builder-input`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
@@ -124,24 +104,6 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToInputs()
|
||||
|
||||
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
|
||||
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Logs tab when terminal panel opens', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between shortcuts and terminal panels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should persist Logs tab content in bottom panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
|
||||
const xtermContainer = bottomPanel.root.locator('.xterm')
|
||||
await expect(xtermContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('should render xterm container in terminal panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
|
||||
const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen')
|
||||
await expect(xtermScreen.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -38,13 +38,16 @@ 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',
|
||||
@@ -99,13 +102,16 @@ 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',
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
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.
|
||||
})
|
||||
@@ -129,74 +129,4 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(undoCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test(
|
||||
'Copy paste node, image paste onto LoadImage, image paste on empty canvas',
|
||||
{ tag: ['@node'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
|
||||
// Step 1: Copy a KSampler node with Ctrl+C and paste with Ctrl+V
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clipboard.paste()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(3)
|
||||
|
||||
// Step 2: Paste image onto selected LoadImage node
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
await comfyPage.clipboard.pasteFile(
|
||||
comfyPage.assetPath('image32x32.webp')
|
||||
)
|
||||
await uploadPromise
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const fileWidget = await loadImageNodes[0].getWidget(0)
|
||||
return fileWidget.getValue()
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toContain('image32x32')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
|
||||
// Step 3: Click empty canvas area, paste image → creates new LoadImage
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise2 = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
await comfyPage.clipboard.pasteFile(
|
||||
comfyPage.assetPath('image32x32.webp')
|
||||
)
|
||||
await uploadPromise2
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(4)
|
||||
const allLoadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
expect(allLoadImageNodes).toHaveLength(2)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,124 +9,63 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show error overlay when loading a workflow with missing nodes', async ({
|
||||
test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing.*installed/i)
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
|
||||
test('Should display a warning when loading a workflow with missing nodes in subgraphs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await expect(missingNodesWarning).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
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
// Expand the pack group row to reveal node type names
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab when clicking See Errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the right side panel errors tab
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify MissingNodeCard is rendered in the errors tab
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
// Verify the missing node text includes subgraph context
|
||||
const warningText = await missingNodesWarning.textContent()
|
||||
expect(warningText).toContain('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
expect(warningText).toContain('in subgraph')
|
||||
})
|
||||
})
|
||||
|
||||
test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Make a change to the graph by moving a node
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
|
||||
await comfyPage.page.mouse.up()
|
||||
// Wait for any async operations to complete after dialog closes
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Undo and redo should not resurface the error overlay
|
||||
// Make a change to the graph
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
|
||||
// Undo and redo the change
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
@@ -147,51 +86,11 @@ test.describe('Execution error', () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the error overlay to be visible
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for error overlay and click "See Errors"
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
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.getByTestId(
|
||||
TestIds.dialogs.errorCardFindOnGithub
|
||||
)
|
||||
await expect(findOnGithubButton).toBeVisible()
|
||||
|
||||
// Verify Copy button is present in the error card
|
||||
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
|
||||
await expect(copyButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -211,16 +110,11 @@ test.describe('Missing models in Error Tab', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show missing models from node properties', async ({
|
||||
@@ -230,16 +124,11 @@ test.describe('Missing models in Error Tab', () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not show missing models when widget values have changed', async ({
|
||||
@@ -249,12 +138,11 @@ test.describe('Missing models in Error Tab', () => {
|
||||
'missing/model_metadata_widget_mismatch'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).not.toBeVisible()
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -264,9 +152,7 @@ test.describe('Missing models in Error Tab', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
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 }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function triggerExecutionError(comfyPage: {
|
||||
canvasOps: { disconnectEdge: () => Promise<void> }
|
||||
page: Page
|
||||
command: { executeCommand: (cmd: string) => Promise<void> }
|
||||
}) {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
}
|
||||
|
||||
test('Error overlay appears on execution error', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Error overlay shows error message', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('"Dismiss" closes overlay without opening panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId('properties-panel')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
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')
|
||||
@@ -25,14 +24,16 @@ test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.getByRole('button', { name: 'Dismiss' })
|
||||
.click()
|
||||
await errorOverlay.waitFor({ state: 'hidden' })
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.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 |
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Focus Mode', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Focus mode hides UI chrome', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(true)
|
||||
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode restores UI chrome', async ({ comfyPage }) => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(false)
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle focus mode command works', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode hides topbar', async ({ comfyPage }) => {
|
||||
const topMenu = comfyPage.page.locator('.comfy-menu-button-wrapper')
|
||||
await expect(topMenu).toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(true)
|
||||
|
||||
await expect(topMenu).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(false)
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -23,85 +23,4 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
|
||||
})
|
||||
|
||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||
// conversion) caused the wrong link to survive during deduplication.
|
||||
// Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links).
|
||||
// Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5).
|
||||
// Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and
|
||||
// removed 276, breaking the cfg connection on KSamplerAdvanced 85.
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291
|
||||
test('Deduplicates links without breaking connections on slot-drift workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||
const cfgInput85 = ksampler85.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfgInput86 = ksampler86.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfg85Linked = cfgInput85?.link != null
|
||||
const cfg86Linked = cfgInput86?.link != null
|
||||
|
||||
// Verify the surviving links exist in the subgraph link map
|
||||
const cfg85LinkValid =
|
||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||
const cfg86LinkValid =
|
||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||
|
||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
return {
|
||||
cfg85Linked,
|
||||
cfg86Linked,
|
||||
cfg85LinkValid,
|
||||
cfg86LinkValid,
|
||||
cfg85LinkId: cfgInput85?.link ?? null,
|
||||
cfg86LinkId: cfgInput86?.link ?? null,
|
||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||
expect(result.cfg85Linked).toBe(true)
|
||||
expect(result.cfg86Linked).toBe(true)
|
||||
// Links must exist in the subgraph link map
|
||||
expect(result.cfg85LinkValid).toBe(true)
|
||||
expect(result.cfg86LinkValid).toBe(true)
|
||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||
expect(result.switchOutputLinkCount).toBe(2)
|
||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||
expect(result.cfgLinkToNode85Count).toBe(1)
|
||||
// Output link IDs must match the input link IDs (source/target integrity)
|
||||
expect(result.switchOutputLinkIds).toEqual(
|
||||
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,15 +60,6 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Fit view button is present and clickable', async ({ comfyPage }) => {
|
||||
const fitViewButton = comfyPage.page
|
||||
.locator('button')
|
||||
.filter({ has: comfyPage.page.locator('.icon-\\[lucide--focus\\]') })
|
||||
await expect(fitViewButton).toBeVisible()
|
||||
await fitViewButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Zoom controls popup opens and closes', async ({ comfyPage }) => {
|
||||
// Find the zoom button by its percentage text content
|
||||
const zoomButton = comfyPage.page.locator('button').filter({
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '../fixtures/components/SidebarTab'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
@@ -225,7 +224,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 77 KiB |
@@ -1,91 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Job History Actions', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
|
||||
// Expand the queue overlay so the JobHistoryActionsMenu is visible
|
||||
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
|
||||
})
|
||||
|
||||
async function openMoreOptionsPopover(comfyPage: {
|
||||
page: { getByLabel(label: string | RegExp): Locator }
|
||||
}) {
|
||||
const moreButton = comfyPage.page.getByLabel(/More options/i).first()
|
||||
await moreButton.click()
|
||||
}
|
||||
|
||||
test('More options popover opens', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="docked-job-history-action"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Docked job history action is visible with text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
const action = comfyPage.page.locator(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await expect(action).toBeVisible()
|
||||
await expect(action).not.toBeEmpty()
|
||||
})
|
||||
|
||||
test('Show run progress bar action is visible', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="show-run-progress-bar-action"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clear history action is visible', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="clear-history-action"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking docked job history closes popover', async ({ comfyPage }) => {
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
const action = comfyPage.page.locator(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await expect(action).toBeVisible()
|
||||
await action.click()
|
||||
|
||||
await expect(action).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking show run progress bar toggles setting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingBefore = await comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.Queue.ShowRunProgressBar'
|
||||
)
|
||||
|
||||
await openMoreOptionsPopover(comfyPage)
|
||||
|
||||
const action = comfyPage.page.locator(
|
||||
'[data-testid="show-run-progress-bar-action"]'
|
||||
)
|
||||
await action.click()
|
||||
|
||||
const settingAfter = await comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.Queue.ShowRunProgressBar'
|
||||
)
|
||||
expect(settingAfter).toBe(!settingBefore)
|
||||
})
|
||||
})
|
||||
@@ -1,257 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
const TEST_PRESET = {
|
||||
name: 'test-preset',
|
||||
newBindings: [
|
||||
{
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
combo: { key: 'a', ctrl: true, shift: true },
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
],
|
||||
unsetBindings: [
|
||||
{
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
combo: { key: 'a', ctrl: true },
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function importPreset(page: Page, preset: typeof TEST_PRESET) {
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
await menuButton.click()
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('menuitem', { name: /Import preset/i }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
|
||||
const presetPath = path.join(os.tmpdir(), 'test-preset.json')
|
||||
fs.writeFileSync(presetPath, JSON.stringify(preset))
|
||||
await fileChooser.setFiles(presetPath)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.request.fetch(
|
||||
`${comfyPage.url}/api/userdata/keybindings%2Ftest-preset.json`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Keybinding.CurrentPreset',
|
||||
'default'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
|
||||
test('Can import a preset, use remapped keybinding, and switch back to default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
|
||||
// Verify default Ctrl+A select-all works
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss, then close settings via Escape
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Load workflow again, use new keybind Ctrl+Shift+A
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvas.press('Control+Shift+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to default preset
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await presetTrigger.click()
|
||||
await page.getByRole('option', { name: /Default Preset/i }).click()
|
||||
|
||||
// Handle unsaved changes dialog if the preset was marked as modified
|
||||
const discardButton = page.getByRole('button', {
|
||||
name: /Discard and Switch/i
|
||||
})
|
||||
if (await discardButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await discardButton.click()
|
||||
}
|
||||
|
||||
await expect(presetTrigger).toContainText('Default Preset')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
})
|
||||
|
||||
test('Can export a preset and re-import it', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Export via ellipsis menu
|
||||
await menuButton.click()
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('menuitem', { name: /Export preset/i }).click()
|
||||
const download = await downloadPromise
|
||||
|
||||
// Verify filename contains test-preset
|
||||
expect(download.suggestedFilename()).toContain('test-preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Verify the downloaded file is valid JSON with correct structure
|
||||
const downloadPath = await download.path()
|
||||
expect(downloadPath).toBeTruthy()
|
||||
const content = fs.readFileSync(downloadPath!, 'utf-8')
|
||||
const parsed = JSON.parse(content) as {
|
||||
name: string
|
||||
newBindings: unknown[]
|
||||
unsetBindings: unknown[]
|
||||
}
|
||||
expect(parsed).toHaveProperty('name')
|
||||
expect(parsed).toHaveProperty('newBindings')
|
||||
expect(parsed).toHaveProperty('unsetBindings')
|
||||
expect(parsed.name).toBe('test-preset')
|
||||
})
|
||||
|
||||
test('Can delete an imported preset', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Delete via ellipsis menu
|
||||
await menuButton.click()
|
||||
await page.getByRole('menuitem', { name: /Delete preset/i }).click()
|
||||
|
||||
// Confirm deletion in the dialog
|
||||
const confirmDialog = page.getByRole('dialog', {
|
||||
name: /Delete the current preset/i
|
||||
})
|
||||
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
|
||||
|
||||
// Verify preset trigger now shows Default Preset
|
||||
await expect(presetTrigger).toContainText('Default Preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
})
|
||||
|
||||
test('Can save modifications as a new preset', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
const { page } = comfyPage
|
||||
const menuButton = page.getByTestId('keybinding-preset-menu')
|
||||
|
||||
// Open keybinding settings panel
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
|
||||
await importPreset(page, TEST_PRESET)
|
||||
|
||||
// Verify active preset switched to test-preset
|
||||
const presetTrigger = page
|
||||
.locator('#keybinding-panel-actions')
|
||||
.locator('button[role="combobox"]')
|
||||
await expect(presetTrigger).toContainText('test-preset')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Save as new preset via ellipsis menu
|
||||
await menuButton.click()
|
||||
await page.getByRole('menuitem', { name: /Save as new preset/i }).click()
|
||||
|
||||
// Fill in the preset name in the prompt dialog
|
||||
const promptInput = page.locator('.prompt-dialog-content input')
|
||||
await promptInput.fill('my-custom-preset')
|
||||
await promptInput.press('Enter')
|
||||
|
||||
// Wait for toast to auto-dismiss
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Verify preset trigger shows my-custom-preset
|
||||
await expect(presetTrigger).toContainText('my-custom-preset')
|
||||
|
||||
// Close settings
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.settingDialog.waitForHidden()
|
||||
|
||||
// Cleanup: delete the my-custom-preset file
|
||||
await comfyPage.request.fetch(
|
||||
`${comfyPage.url}/api/userdata/keybindings%2Fmy-custom-preset.json`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas not visible in app mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(comfyPage.canvas).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -67,44 +67,5 @@ test.describe(
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('Load workflow from URL dropped onto Vue node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fakeUrl = 'https://example.com/workflow.png'
|
||||
await comfyPage.page.route(fakeUrl, (route) =>
|
||||
route.fulfill({
|
||||
path: comfyPage.assetPath('workflowInMedia/workflow_itxt.png')
|
||||
})
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
|
||||
const dropPosition = {
|
||||
x: box!.x + box!.width / 2,
|
||||
y: box!.y + box!.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
|
||||
dropPosition,
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
|
||||
await comfyPage.page.waitForFunction(
|
||||
(prevCount) => window.app!.graph.nodes.length !== prevCount,
|
||||
initialNodeCount,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newNodeCount).not.toBe(initialNodeCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -1,7 +1,6 @@
|
||||
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')
|
||||
@@ -39,7 +38,7 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/only_optional_inputs')
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
).not.toBeVisible()
|
||||
|
||||
// If the node's multiline text widget is visible, then it was loaded successfully
|
||||
@@ -74,6 +73,10 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
|
||||
})
|
||||
test('unknown converted widget', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.ShowMissingNodesWarning',
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_nodes_converted_widget'
|
||||
)
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Enable the essentials feature flag via the reactive serverFeatureFlags ref.
|
||||
// In production, this flag comes via WebSocket or remoteConfig (cloud only).
|
||||
// The localhost test server has neither, so we set it directly.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
node_library_essentials_enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
// Register a mock essential node so the essentials tab has content.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
return window.app!.registerNodeDef('TestEssentialNode', {
|
||||
name: 'TestEssentialNode',
|
||||
display_name: 'Test Essential Node',
|
||||
category: 'essentials_test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false],
|
||||
output_node: false,
|
||||
python_module: 'comfy_essentials.nodes',
|
||||
description: 'Mock essential node for testing',
|
||||
essentials_category: 'Image Generation'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Node library opens via sidebar', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const sidebarContent = comfyPage.page.locator(
|
||||
'.comfy-vue-side-bar-container'
|
||||
)
|
||||
await expect(sidebarContent).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await expect(essentialsTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking essentials tab shows essential node cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essential node cards have node names', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const firstCard = comfyPage.page.locator('[data-node-name]').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
const nodeName = await firstCard.getAttribute('data-node-name')
|
||||
expect(nodeName).toBeTruthy()
|
||||
expect(nodeName!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Node library can switch between all and essentials tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
const allNodesTab = comfyPage.page.getByRole('tab', { name: /^all$/i })
|
||||
|
||||
await essentialsTab.click()
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
|
||||
await allNodesTab.click()
|
||||
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
})
|
||||
@@ -79,7 +79,6 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
'Can auto link batch moved node',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
|
||||
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
|
||||
|
||||
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
})
|
||||
|
||||
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await expect(searchBoxV2.dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape closes search box without adding node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test('Search clears when reopening', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await expect(searchBoxV2.input).toHaveValue('')
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Category navigation updates results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const samplingResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
await searchBoxV2.categoryButton('loaders').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const loaderResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
expect(samplingResults).not.toEqual(loaderResults)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Filter workflow', () => {
|
||||
test('Filter chip removal restores results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// Record initial result text for comparison
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
// Apply Input filter with MODEL type
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Verify filter chip appeared and results changed
|
||||
const filterChip = searchBoxV2.dialog.locator(
|
||||
'[data-testid="filter-chip"]'
|
||||
)
|
||||
await expect(filterChip).toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const filteredResults = await searchBoxV2.results.allTextContents()
|
||||
expect(filteredResults).not.toEqual(unfilteredResults)
|
||||
|
||||
// Remove filter by clicking the chip delete button
|
||||
await filterChip.getByTestId('chip-delete').click()
|
||||
|
||||
// Filter chip should be removed
|
||||
await expect(filterChip).not.toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowUp on first item keeps first selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
const results = searchBoxV2.results
|
||||
await expect(results.first()).toBeVisible()
|
||||
|
||||
// First result should be selected by default
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// ArrowUp on first item should keep first selected
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
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')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
Before Width: | Height: | Size: 24 KiB |