Compare commits

..

4 Commits

Author SHA1 Message Date
pythongosssss
89e634d431 clear workflow state on close 2026-03-17 09:57:24 -07:00
pythongosssss
49e052ef97 rename statuses, add aria label 2026-03-17 09:33:19 -07:00
pythongosssss
83eed12c0e fix race with socket event 2026-03-17 06:43:50 -07:00
pythongosssss
e2d96e05f9 add tab status indicator (running/done/errored) 2026-03-17 06:28:27 -07:00
646 changed files with 8401 additions and 33139 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 |

View File

@@ -173,19 +173,6 @@ jobs:
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: >

View File

@@ -30,7 +30,6 @@ concurrency:
jobs:
bump-version:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -18,7 +18,6 @@ concurrency:
jobs:
docs-check:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:

View File

@@ -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"
},

View File

@@ -51,9 +51,6 @@
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/
.cursor/

View File

@@ -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

View File

@@ -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))

View File

@@ -1,2 +0,0 @@
dist/
.astro/

View File

@@ -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
}
}
})

View File

@@ -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"
}
}
}
}
}

View File

@@ -1 +0,0 @@
/// <reference types="astro/client" />

View File

@@ -1,2 +0,0 @@
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';

View File

@@ -1,9 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -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
}

View File

@@ -1,172 +0,0 @@
{
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 4,
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"pos": [689.0083557128902, 467.9999999999997],
"size": [431.8999938964844, 206.60000610351562],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["3", "text", "2"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [330, 367, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [983, 367, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"pos": [510, 166],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["11111111111"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [523, 438],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["22222222222"]
}
],
"groups": [],
"links": [],
"extra": {}
},
{
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [467, 446, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [932, 446, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"pos": [647, 389],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "text"],
["2", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 2.0975,
"offset": [-581.4780189305006, -356.3000030517576]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -1,555 +0,0 @@
{
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
"revision": 0,
"last_node_id": 11,
"last_link_id": 16,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": [1451.0058559453123, 189.0019842294924],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [25.988896564209426, 473.9973077158204],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [11]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [10]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"pos": [711.776576770508, 420.55569028417983],
"size": [400, 293],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 10
},
{
"name": "model",
"type": "MODEL",
"link": 11
},
{
"name": "vae",
"type": "VAE",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [13]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"],
["3", "seed"]
]
},
"widgets_values": []
}
],
"links": [
[10, 4, 1, 10, 0, "CLIP"],
[11, 4, 0, 10, 1, "MODEL"],
[12, 4, 2, 10, 2, "VAE"],
[13, 10, 0, 9, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233, 404.5, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1494, 424.5, 120, 60]
},
"inputs": [
{
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
"name": "clip",
"type": "CLIP",
"linkIds": [14],
"localized_name": "clip",
"pos": [333, 424.5]
},
{
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [333, 444.5]
},
{
"id": "e61199fa-9113-4532-a3d9-879095969171",
"name": "vae",
"type": "VAE",
"linkIds": [8],
"localized_name": "vae",
"pos": [333, 464.5]
}
],
"outputs": [
{
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [9],
"localized_name": "IMAGE",
"pos": [1514, 444.5]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473.007643669922, 609.0214689174805],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [862.990643669922, 185.9853293300783],
"size": [400, 317],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 16
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209.0062878349609, 188.00400724755877],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 11,
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"pos": [485.5190761650391, 283.9247189174806],
"size": [400, 237],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [15]
},
{
"localized_name": "CONDITIONING_1",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"links": [16]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 2,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 9,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": 11,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
},
{
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
]
},
"inputs": [
{
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
"name": "clip",
"type": "CLIP",
"linkIds": [5, 3],
"localized_name": "clip",
"pos": [55, 20]
}
],
"outputs": [
{
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "CONDITIONING",
"pos": [20, 20]
},
{
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "CONDITIONING_1",
"pos": [20, 40]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413.01228575000005, 388.98593823266606],
"size": [425, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [414.99053247091683, 185.9946096918335],
"size": [423, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6830134553650709,
"offset": [-203.70966200000038, 259.92420099999975]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -1,407 +0,0 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -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'
@@ -33,14 +33,13 @@ import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
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
@@ -177,7 +176,6 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -223,13 +221,12 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
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)
@@ -290,7 +287,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()
@@ -310,16 +309,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)

View File

@@ -1,201 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class AppModeHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
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"]')
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").
*/
getAppModeWidgetMenu(widgetName: string): Locator {
return this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInputViaMenu(title: string, newName: string) {
const menu = this.getBuilderInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title to trigger
* inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.filter({ hasText: title })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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 })
}

View File

@@ -33,7 +33,6 @@ export class NodeOperationsHelper {
})
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}

View File

@@ -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,

View File

@@ -1,4 +1,3 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
@@ -7,7 +6,6 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
@@ -324,93 +322,4 @@ export class SubgraphHelper {
)
await this.comfyPage.nextFrame()
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async exitViaBreadcrumb(): Promise<void> {
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
const parentLink = breadcrumb.getByRole('link').first()
if (await parentLink.isVisible()) {
await parentLink.click()
} else {
await this.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph
if (!graph) return
canvas.setGraph(graph.rootGraph)
})
}
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
}
async countGraphPseudoPreviewEntries(): Promise<number> {
return this.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.reduce((count, node) => {
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return count
return (
count +
proxyWidgets.filter(
(entry) =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[1] === 'string' &&
entry[1].startsWith('$$')
).length
)
}, 0)
})
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
async getNodeCount(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
}

View File

@@ -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'
}

View File

@@ -28,18 +28,9 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
whatsNewSection: 'whats-new-section'
},
topbar: {
queueButton: 'queue-button',
@@ -62,18 +53,11 @@ export const TestIds = {
colorRed: 'red'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
@@ -83,10 +67,6 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -98,18 +78,15 @@ 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]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -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')

View File

@@ -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'

View File

@@ -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'])

View File

@@ -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) {

View File

@@ -2,11 +2,6 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
@@ -37,28 +32,6 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -75,26 +48,6 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
export async function getPseudoPreviewWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -1,5 +1,3 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -16,30 +14,3 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -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)
})
})

View File

@@ -1,187 +0,0 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
await comfyPage.canvasOps.setScale(1)
await subgraphNode.centerOnNode()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
// Node is centered on screen, so click the canvas center
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Rename from builder input-select sidebar via menu', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.goToInputs()
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-menu`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(
appMode.linearWidgets.getByText('Builder Input Seed')
).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)
await appMode.goToPreview()
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
})
test('Rename from app mode', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
// Verify persistence after save/reload
await appMode.toggleAppMode()
const workflowName = `${new Date().getTime()} app-mode`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
})
})

View File

@@ -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',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -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)
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -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')
})
})
})

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,13 +42,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -77,9 +75,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -148,44 +144,6 @@ test.describe('Execution error', () => {
})
})
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')
@@ -210,7 +168,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -226,7 +184,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -237,10 +195,13 @@ 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.getByText(/Missing Models/)).not.toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
})
// Flaky test after parallelization

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -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])
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,82 +829,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Some files were not shown because too many files have changed in this diff Show More