Compare commits
47 Commits
refactor/m
...
jaewon/m1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4991802e8b | ||
|
|
4acd50cd85 | ||
|
|
02b8cc6882 | ||
|
|
752452b905 | ||
|
|
519aefd382 | ||
|
|
0a8f936f9d | ||
|
|
b50510f7d8 | ||
|
|
d8422691cc | ||
|
|
15108848d5 | ||
|
|
076cba9611 | ||
|
|
37f9a14e15 | ||
|
|
bec9e3714d | ||
|
|
d45e64197c | ||
|
|
4b0f6dc293 | ||
|
|
117d011900 | ||
|
|
c244b53d0b | ||
|
|
4837307fa6 | ||
|
|
3051c00254 | ||
|
|
77514a4c82 | ||
|
|
2fd67256fd | ||
|
|
e35bb25d7c | ||
|
|
2184d25dcb | ||
|
|
98642301fe | ||
|
|
55b0329203 | ||
|
|
c9d9ee1ee5 | ||
|
|
9b9b0f2457 | ||
|
|
c579c88e90 | ||
|
|
0797b7af7a | ||
|
|
1d71c93cad | ||
|
|
b38fab4f66 | ||
|
|
ffe8d0f2ee | ||
|
|
ec0711d62e | ||
|
|
e48dcd11f1 | ||
|
|
5b1446a794 | ||
|
|
2a5de94145 | ||
|
|
c3cde8dd6a | ||
|
|
09943f855a | ||
|
|
b34026527a | ||
|
|
fcdc4404eb | ||
|
|
7f6d354a8e | ||
|
|
7f9804e2bd | ||
|
|
5809f55171 | ||
|
|
29a86503fc | ||
|
|
45402c6b82 | ||
|
|
e927025ee3 | ||
|
|
48883269cc | ||
|
|
c5dd38f089 |
@@ -32,12 +32,12 @@
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit`
|
||||
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit "${TEST_FILES[@]}"
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit <test-file>
|
||||
pnpm test:unit -- <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
|
||||
@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit --update
|
||||
pnpm test:unit -- --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
@@ -19,5 +19,8 @@ runs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp -r ./tools/devtools/* /ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
cd /ComfyUI && python3 main.py --cpu --multi-user --front-end-root "${{ inputs.front_end_root }}" &
|
||||
# TODO(FE-729): remove --enable-assets once BE-786 lands in the CI ComfyUI image
|
||||
# (BE-786 removes the gate so /api/assets is always on). Until then, FE-729
|
||||
# routes modelStore through assetService, which 503s without this flag.
|
||||
cd /ComfyUI && python3 main.py --cpu --multi-user --enable-assets --front-end-root "${{ inputs.front_end_root }}" &
|
||||
wait-for-it --service 127.0.0.1:8188 -t ${{ inputs.timeout }}
|
||||
|
||||
6
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -45,8 +45,12 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: pnpm build:cloud
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
2
.github/workflows/release-draft-create.yaml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
pnpm build
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
5
.gitignore
vendored
@@ -19,7 +19,6 @@ yarn.lock
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.nx
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -90,6 +89,10 @@ storybook-static
|
||||
# MCP Servers
|
||||
.playwright-mcp/*
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
".i18nrc.cjs",
|
||||
".nx/*",
|
||||
"**/vite.config.*.timestamp*",
|
||||
"**/vitest.config.*.timestamp*",
|
||||
"components.d.ts",
|
||||
|
||||
17
AGENTS.md
@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
|
||||
The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Package Manager
|
||||
|
||||
@@ -237,6 +237,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Architecture Decision Records
|
||||
@@ -307,20 +308,6 @@ When referencing Comfy-Org repos:
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER add multi-line block comments to justify trivial code changes
|
||||
- A one-line fix does not need a three-line comment explaining why
|
||||
- A guard clause that mirrors another file does not need a comment naming that file
|
||||
- A test setup line does not need a comment paraphrasing what the next line does
|
||||
- If the diff is small and obvious, the comment is noise — write the code and move on
|
||||
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
|
||||
- **Penance protocol when you catch yourself adding one of these comments:**
|
||||
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
|
||||
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
|
||||
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
|
||||
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
|
||||
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
|
||||
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
|
||||
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
|
||||
@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Having Issues?] --> B{What's the problem?}
|
||||
B -->|Dev server stuck| C[pnpm dev hangs]
|
||||
B -->|Dev server stuck| C[nx serve hangs]
|
||||
B -->|Build errors| D[Check build issues]
|
||||
B -->|Lint errors| Q[Check linting issues]
|
||||
B -->|Dependency issues| E[Package problems]
|
||||
@@ -23,7 +23,7 @@ flowchart TD
|
||||
G -->|No| H[Run: pnpm i]
|
||||
G -->|Still stuck| I[Run: pnpm clean]
|
||||
I --> J{Still stuck?}
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
@@ -41,11 +41,11 @@ flowchart TD
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` gets stuck and won't start
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs during Vite startup
|
||||
- Command hangs on "nx serve"
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
@@ -65,7 +65,7 @@ flowchart TD
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm clean:all && pnpm i
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
@@ -73,7 +73,7 @@ flowchart TD
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- stale local build cache
|
||||
- NX cache corruption
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
|
||||
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
|
||||
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
|
||||
"lint": "eslint src --cache",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
@@ -36,5 +33,88 @@
|
||||
"vite-plugin-html": "catalog:",
|
||||
"vite-plugin-vue-devtools": "catalog:",
|
||||
"vue-tsc": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:desktop",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite preview --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook dev -p 6007"
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook build -o dist/storybook"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/storybook"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "eslint src --cache"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,5 +45,88 @@
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "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"
|
||||
}
|
||||
},
|
||||
"test:unit": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run"
|
||||
}
|
||||
},
|
||||
"test:coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run --coverage"
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "playwright test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 17 KiB |
14
apps/website/public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
apps/website/public/icons/clients/EA.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<GlassCard
|
||||
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
|
||||
>
|
||||
|
||||
@@ -74,7 +74,7 @@ useHeroAnimation({
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/about/co-founders.webm"
|
||||
poster="https://media.comfy.org/website/about/co-founders-poster.webp"
|
||||
|
||||
@@ -33,7 +33,7 @@ const values: {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto max-w-5xl text-center">
|
||||
<SectionLabel>
|
||||
{{ t('about.values.label', locale) }}
|
||||
|
||||
@@ -16,7 +16,7 @@ const investors = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
|
||||
@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
|
||||
<template #right-card>
|
||||
<img
|
||||
|
||||
@@ -41,7 +41,7 @@ function toggle(index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<section class="px-4 py-24 md:px-20 md:py-40">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
@@ -46,9 +46,7 @@ const cards = excludeProduct
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-center px-4 text-center">
|
||||
<SectionLabel v-if="labelKey">
|
||||
|
||||
@@ -3,6 +3,7 @@ const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'EA',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
|
||||
@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
|
||||
<section class="px-6 py-16 lg:px-16 lg:py-24">
|
||||
<!-- Scrollable track -->
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
|
||||
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
|
||||
>
|
||||
<div
|
||||
v-for="(fb, i) in feedbacks"
|
||||
|
||||
@@ -72,7 +72,7 @@ function handleLogoLoad() {
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/customers/blackmath/video.webm"
|
||||
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"
|
||||
|
||||
@@ -10,7 +10,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
>
|
||||
<a
|
||||
v-for="story in customerStories"
|
||||
|
||||
@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-20 lg:py-40">
|
||||
<section class="px-6 py-16 lg:px-20 lg:py-40">
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/customers/silverside/video.webm"
|
||||
poster="https://media.comfy.org/website/customers/silverside/poster.webp"
|
||||
|
||||
@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
|
||||
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
|
||||
@@ -223,10 +223,7 @@ while (idx < items.length) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="gallery-grid"
|
||||
class="max-w-9xl mx-auto px-4 pb-20 lg:px-20"
|
||||
>
|
||||
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
|
||||
<!-- Desktop grid -->
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 hidden flex-col gap-2 p-2 lg:flex"
|
||||
|
||||
@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-36 pb-16 text-center"
|
||||
>
|
||||
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
|
||||
<SectionLabel>
|
||||
{{ t('gallery.label', locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
@@ -15,7 +15,7 @@ const row2 = [
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
|
||||
class="bg-primary-comfy-ink flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
|
||||
>
|
||||
<!-- Node rows -->
|
||||
<div
|
||||
|
||||
@@ -12,9 +12,7 @@ const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
|
||||
<GlassCard
|
||||
class="flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
|
||||
>
|
||||
|
||||
@@ -36,9 +36,7 @@ const steps = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:gap-8">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
@@ -15,7 +15,7 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
ref="logoContainer"
|
||||
|
||||
@@ -55,10 +55,7 @@ watch(activeIndex, (current, previous) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
|
||||
<!-- Section header -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<NodeBadge :segments="badgeSegments" segment-class="" />
|
||||
|
||||
@@ -121,7 +121,7 @@ const activePlanIndex = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-14">
|
||||
<!-- Header -->
|
||||
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
|
||||
<h1
|
||||
@@ -135,7 +135,7 @@ const activePlanIndex = ref(0)
|
||||
</div>
|
||||
|
||||
<!-- Mobile plan tabs -->
|
||||
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
|
||||
<div class="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
|
||||
<button
|
||||
v-for="(plan, index) in plans"
|
||||
:key="plan.id"
|
||||
|
||||
@@ -60,7 +60,7 @@ const features: IncludedFeature[] = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
|
||||
<!-- Heading -->
|
||||
<div
|
||||
|
||||
@@ -25,7 +25,7 @@ const cards = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<section class="px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-transparency-white-t4 rounded-5xl max-w-9xl mx-auto mt-4 mb-24 p-2 px-4 lg:mt-8 lg:mb-40 lg:px-20"
|
||||
class="bg-transparency-white-t4 rounded-5xl mx-4 mt-4 mb-24 p-2 lg:mx-20 lg:mt-8 lg:mb-40"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-comfy-yellow flex flex-col gap-24 rounded-4xl p-8 lg:flex-row lg:items-end lg:justify-between"
|
||||
|
||||
@@ -442,7 +442,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
|
||||
<section class="px-4 py-24 lg:px-20">
|
||||
<GlassCard
|
||||
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-16"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40">
|
||||
<section class="px-4 py-24 lg:px-20 lg:py-40">
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl flex flex-col-reverse items-stretch gap-10 p-2 lg:flex-row lg:gap-8"
|
||||
>
|
||||
|
||||
@@ -77,9 +77,7 @@ function getCardClass(layoutClass: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
|
||||
>
|
||||
<section class="bg-primary-comfy-ink px-4 py-24 lg:px-20 lg:py-40">
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase"
|
||||
|
||||
@@ -11,7 +11,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
|
||||
<section class="px-4 py-24 lg:px-20">
|
||||
<SectionHeader>
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
|
||||
@@ -22,7 +22,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
|
||||
<section class="px-4 py-24 lg:px-20">
|
||||
<SectionHeader>
|
||||
{{ heading }}
|
||||
<template #subtitle>
|
||||
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
|
||||
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
|
||||
>
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
@@ -71,20 +71,7 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["fe746_photo.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["first-host", 11]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [900, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["second-host", 22]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["9999", "missing_widget"]]
|
||||
},
|
||||
"widgets_values": ["quarantined-host-value"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -66,34 +66,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.drop(options)
|
||||
}
|
||||
|
||||
async middleDrag(
|
||||
from: Position,
|
||||
to: Position,
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await this.dragAndDrop(from, to, { ...options, button: 'middle' })
|
||||
}
|
||||
|
||||
async middleDragFromCenter(
|
||||
locator: Locator,
|
||||
delta: { x: number; y: number },
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await locator.waitFor({ state: 'visible' })
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('middleDragFromCenter: bounding box not found')
|
||||
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
await this.middleDrag(
|
||||
start,
|
||||
{ x: start.x + delta.x, y: start.y + delta.y },
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
/** @see {@link Mouse.move} */
|
||||
async move(to: Position, options = ComfyMouse.defaultOptions) {
|
||||
await this.mouse.move(to.x, to.y, options)
|
||||
|
||||
@@ -213,8 +213,7 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment),
|
||||
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ export class SubgraphEditor {
|
||||
)
|
||||
}
|
||||
|
||||
async ensureOpen(subgraphNode: Locator) {
|
||||
async open(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
if (await this.root.isVisible()) return
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
@@ -70,7 +69,7 @@ export class SubgraphEditor {
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.ensureOpen(subgraphNode)
|
||||
await this.open(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
|
||||
@@ -6,9 +6,8 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
*
|
||||
* Widgets are located by `nodeId:widgetName` suffix against the
|
||||
* `data-widget-key` attribute, which carries the canonical
|
||||
* `graphId:nodeId:widgetName` WidgetEntityId.
|
||||
* Widgets are located by their key (format: "nodeId:widgetName") via the
|
||||
* `data-widget-key` attribute on each widget item.
|
||||
*/
|
||||
export class AppModeWidgetHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -21,9 +20,9 @@ export class AppModeWidgetHelper {
|
||||
return this.comfyPage.appMode.linearWidgets
|
||||
}
|
||||
|
||||
/** Get a widget item container by its `nodeId:widgetName` suffix. */
|
||||
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
|
||||
getWidgetItem(key: string): Locator {
|
||||
return this.container.locator(`[data-widget-key$=":${key}"]`)
|
||||
return this.container.locator(`[data-widget-key="${key}"]`)
|
||||
}
|
||||
|
||||
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
|
||||
|
||||
@@ -119,9 +119,22 @@ export class BuilderSelectHelper {
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
const node = this.comfyPage.vueNodes.getNodeLocator(String(nodeRef.id))
|
||||
// Grid-mode widgets (WidgetSelectDefault) and number widgets expose
|
||||
// aria-label on a wrapper/input. Asset-mode widgets (WidgetSelectDropdown)
|
||||
// do not — the widget name lives in a sibling
|
||||
// [data-testid="widget-layout-field-label"] div, so fall back to clicking
|
||||
// the dropdown trigger button in the same row.
|
||||
const byAriaLabel = node.getByLabel(widgetName, { exact: true })
|
||||
const widgetLocator =
|
||||
(await byAriaLabel.count()) > 0
|
||||
? byAriaLabel
|
||||
: node
|
||||
.getByTestId('widget-layout-field-label')
|
||||
.filter({ hasText: widgetName })
|
||||
.locator('..')
|
||||
.locator('button')
|
||||
.first()
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -216,6 +216,16 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
const node = await this.getFirstNodeRef()
|
||||
if (!node) {
|
||||
throw new Error('No nodes found to convert')
|
||||
}
|
||||
await node.clickContextMenuOption('Convert to Group Node')
|
||||
await this.fillPromptDialog(groupNodeName)
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
await this.promptDialogInput.fill(value)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -14,8 +14,6 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
export class SubgraphHelper {
|
||||
public readonly editor: SubgraphEditor
|
||||
@@ -425,9 +423,39 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
> {
|
||||
return getAllHostPromotedWidgets(this.comfyPage)
|
||||
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). */
|
||||
|
||||
@@ -62,39 +62,12 @@ export class WorkflowHelper {
|
||||
|
||||
async waitForDraftPersisted() {
|
||||
await this.comfyPage.page.waitForFunction(() =>
|
||||
Object.keys(localStorage).some((key) =>
|
||||
key.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Waits for V2 draft index recency, not payload content freshness. */
|
||||
async waitForDraftIndexUpdatedSince(updatedSince: number) {
|
||||
await this.comfyPage.page.waitForFunction((indexUpdatedSince) => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
|
||||
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (!json) continue
|
||||
|
||||
try {
|
||||
const index = JSON.parse(json)
|
||||
if (
|
||||
typeof index.updatedAt === 'number' &&
|
||||
index.updatedAt >= indexUpdatedSince
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed storage while waiting for persistence.
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, updatedSince)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current page and waits for the app to initialize.
|
||||
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and
|
||||
|
||||
@@ -152,7 +152,6 @@ export const TestIds = {
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
valueControl: 'value-control',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button',
|
||||
selectDefaultSearchInput: 'widget-select-default-search-input',
|
||||
|
||||
@@ -511,7 +511,19 @@ export class NodeReference {
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
await this.comfyPage.contextMenu.clickMenuItem(optionText)
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
|
||||
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(`Did not find single group node (found=${nodes.length})`)
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
|
||||
@@ -1,77 +1,48 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
|
||||
function previewExposureToEntry(
|
||||
exposure: PreviewExposure
|
||||
): PromotedWidgetEntry {
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetSource(
|
||||
value: unknown
|
||||
): value is PromotedWidgetSource {
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
}
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
return widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
@@ -107,29 +78,12 @@ export async function getPromotedWidgetCountByName(
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.filter(([, name]) => name === widgetName).length
|
||||
}
|
||||
|
||||
export async function getAllHostPromotedWidgets(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
|
||||
const hostNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => String(node.id))
|
||||
})
|
||||
|
||||
const entries = await Promise.all(
|
||||
hostNodeIds.map(async (hostNodeId) => ({
|
||||
hostNodeId,
|
||||
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
|
||||
}))
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
|
||||
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* FE-230: Deleting an asset must clear the Load Image node preview, widget
|
||||
* value, and mark the workflow dirty.
|
||||
*
|
||||
* Local run (requires cloud build of the frontend):
|
||||
* pnpm build:cloud
|
||||
* pnpm exec playwright test --project=cloud \
|
||||
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
|
||||
* FE-732: Input-asset deletion is no longer gated on `isCloud`; the same
|
||||
* teardown flow now applies to both Cloud and OSS builds. Cloud and OSS
|
||||
* variants below cover both Playwright projects against the shared mock.
|
||||
*
|
||||
* The cloud project is required because input-asset deletion is gated on
|
||||
* `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`).
|
||||
* Local run examples:
|
||||
* pnpm build:cloud && pnpm exec playwright test --project=cloud \
|
||||
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
|
||||
* pnpm build && pnpm exec playwright test --project=chromium \
|
||||
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
|
||||
*/
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
@@ -119,81 +121,88 @@ const baseTest = comfyPageFixture.extend<{ assetMock: AssetMockApi }>({
|
||||
}
|
||||
})
|
||||
|
||||
function registerDeleteFlowTest() {
|
||||
baseTest(
|
||||
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
|
||||
async ({ comfyPage, assetMock }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
// Drive the production drag-and-drop flow to point the Load Image
|
||||
// widget at the asset we are about to delete and populate the preview
|
||||
// cache. FE-230 is asserting that the deletion tears these down.
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
})
|
||||
const imageWidget = await loadImageNode.getWidget(0)
|
||||
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
|
||||
|
||||
// Re-baseline the change tracker so the deletion-side mutation is the
|
||||
// only thing that can flip `isModified` later.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const tracker =
|
||||
window.app?.extensionManager?.workflow?.activeWorkflow?.changeTracker
|
||||
tracker?.reset?.()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
|
||||
// Drive the real production flow: assets sidebar → Imported tab →
|
||||
// right-click asset card → Delete → confirm dialog.
|
||||
const sidebar = comfyPage.menu.assetsTab
|
||||
// The default `open()` waits for assets on the Generated tab; we seed
|
||||
// only an input asset, so skip that wait and let `waitForAssets(1)`
|
||||
// gate on the Imported tab instead.
|
||||
await sidebar.open({ waitForAssets: false })
|
||||
await sidebar.switchToImported()
|
||||
await sidebar.waitForAssets(1)
|
||||
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
|
||||
|
||||
const deleteMenuItem = sidebar.contextMenuItem('Delete')
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
// Mocked DELETE was issued.
|
||||
await expect
|
||||
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
|
||||
.toBe(true)
|
||||
|
||||
// Widget value was cleared.
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('')
|
||||
|
||||
// Preview cache was cleared.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.imgs?.length ?? 0
|
||||
}, loadImageNode.id)
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// Workflow was marked dirty by changeTracker.captureCanvasState().
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
baseTest.describe(
|
||||
'FE-230 asset delete clears Load Image preview',
|
||||
'FE-230 asset delete clears Load Image preview (cloud)',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
baseTest(
|
||||
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
|
||||
async ({ comfyPage, assetMock }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
// Drive the production drag-and-drop flow to point the Load Image
|
||||
// widget at the asset we are about to delete and populate the preview
|
||||
// cache. FE-230 is asserting that the deletion tears these down.
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
})
|
||||
const imageWidget = await loadImageNode.getWidget(0)
|
||||
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
|
||||
|
||||
// Re-baseline the change tracker so the deletion-side mutation is the
|
||||
// only thing that can flip `isModified` later.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const tracker =
|
||||
window.app?.extensionManager?.workflow?.activeWorkflow
|
||||
?.changeTracker
|
||||
tracker?.reset?.()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
|
||||
// Drive the real production flow: assets sidebar → Imported tab →
|
||||
// right-click asset card → Delete → confirm dialog.
|
||||
const sidebar = comfyPage.menu.assetsTab
|
||||
// The default `open()` waits for assets on the Generated tab; we seed
|
||||
// only an input asset, so skip that wait and let `waitForAssets(1)`
|
||||
// gate on the Imported tab instead.
|
||||
await sidebar.open({ waitForAssets: false })
|
||||
await sidebar.switchToImported()
|
||||
await sidebar.waitForAssets(1)
|
||||
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
|
||||
|
||||
const deleteMenuItem = sidebar.contextMenuItem('Delete')
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
// Mocked DELETE was issued.
|
||||
await expect
|
||||
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
|
||||
.toBe(true)
|
||||
|
||||
// Widget value was cleared.
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('')
|
||||
|
||||
// Preview cache was cleared.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.imgs?.length ?? 0
|
||||
}, loadImageNode.id)
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// Workflow was marked dirty by changeTracker.captureCanvasState().
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
}
|
||||
)
|
||||
}
|
||||
registerDeleteFlowTest
|
||||
)
|
||||
|
||||
baseTest.describe(
|
||||
'FE-230 asset delete clears Load Image preview (oss)',
|
||||
{ tag: '@oss' },
|
||||
registerDeleteFlowTest
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-off.png',
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
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
|
||||
|
||||
|
||||
@@ -157,13 +157,6 @@ test.describe('Signin dialog', () => {
|
||||
})
|
||||
|
||||
test('Sign-in dialog resolves true on login', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/customers', (route) =>
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'test-user-e2e', email: 'test@example.com' })
|
||||
})
|
||||
)
|
||||
const dialog = new SignInDialog(comfyPage.page)
|
||||
const { result: dialogResult } = await dialog.openWithResult()
|
||||
|
||||
|
||||
@@ -7,14 +7,9 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_NAME = 'group_node'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
|
||||
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -23,19 +18,22 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
const groupNodeCategory = 'group nodes>workflow'
|
||||
const groupNodeBookmarkName = `workflow>${groupNodeName}`
|
||||
let libraryTab: NodeLibrarySidebarTab
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
libraryTab = comfyPage.menu.nodeLibraryTab
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
|
||||
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
@@ -43,8 +41,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getNode(GROUP_NODE_NAME).click()
|
||||
// Add group node from node library sidebar
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getNode(groupNodeName).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
@@ -53,9 +52,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
@@ -64,12 +63,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([GROUP_NODE_BOOKMARK])
|
||||
.toEqual([groupNodeBookmarkName])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
|
||||
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
|
||||
|
||||
// Unbookmark the node
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
@@ -96,57 +96,72 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
).toBeVisible()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
test('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
test(
|
||||
'Can be added to canvas using search',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(groupNodeName)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${groupNodeName}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
|
||||
.toHaveLength(2)
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const pos = await groupNode.getPosition()
|
||||
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const makeGroup = async (name: string, type1: string, type2: string) => {
|
||||
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
|
||||
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
|
||||
await node1.click('title')
|
||||
await node2.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
return await node2.convertToGroupNode(name)
|
||||
}
|
||||
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
const group1 = await makeGroup(
|
||||
'g1',
|
||||
'CLIPTextEncode',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
|
||||
|
||||
const manage1 = await group1.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
|
||||
await manage.close()
|
||||
await expect(manage.root).toBeHidden()
|
||||
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
|
||||
await manage1.close()
|
||||
await expect(manage1.root).toBeHidden()
|
||||
|
||||
const manage2 = await group2.manageGroupNode()
|
||||
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
@@ -186,6 +201,42 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
|
||||
expect(nodes).toHaveLength(1)
|
||||
return nodes[0]
|
||||
}
|
||||
const latent = await expectSingleNode('EmptyLatentImage')
|
||||
const sampler = await expectSingleNode('KSampler')
|
||||
// Remove existing link
|
||||
const samplerInput = await sampler.getInput(0)
|
||||
await samplerInput.removeLinks()
|
||||
// Group latent + sampler
|
||||
await latent.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
await sampler.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
const groupNode = await sampler.convertToGroupNode()
|
||||
// Connect node to group
|
||||
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
|
||||
const input = await ckpt.connectOutput(0, groupNode, 0)
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
// Modify the group node via manage dialog
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await manage.selectNode('KSampler')
|
||||
await manage.changeTab('Inputs')
|
||||
await manage.setLabel('model', 'test')
|
||||
await manage.save()
|
||||
await manage.close()
|
||||
// Ensure the link is still present
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -198,6 +249,11 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.describe('Copy and paste', () => {
|
||||
let groupNode: NodeReference | null
|
||||
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_PREFIX = 'workflow>'
|
||||
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
|
||||
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
|
||||
|
||||
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
|
||||
return await comfyPage.page.evaluate((nodeType: string) => {
|
||||
@@ -226,10 +282,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
|
||||
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
@@ -243,7 +299,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test('Copies and pastes group node after clearing workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Set setting
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
// Clear workflow
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
|
||||
await comfyPage.clipboard.paste()
|
||||
@@ -283,6 +342,24 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Convert to group node, no selection', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
@@ -76,34 +72,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag should pan the mask editor canvas',
|
||||
{ tag: ['@canvas'] },
|
||||
async ({ comfyPage, comfyMouse, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
const pointerZone = dialog.getByTestId('pointer-zone')
|
||||
const getCanvasPosition = () =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const container = document.querySelector('#maskEditorCanvasContainer')
|
||||
if (!(container instanceof HTMLElement)) return null
|
||||
|
||||
return {
|
||||
left: container.style.left,
|
||||
top: container.style.top
|
||||
}
|
||||
})
|
||||
const canvasPositionBefore = await getCanvasPosition()
|
||||
|
||||
await comfyMouse.middleDragFromCenter(
|
||||
pointerZone,
|
||||
{ x: 140, y: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect.poll(getCanvasPosition).not.toEqual(canvasPositionBefore)
|
||||
}
|
||||
)
|
||||
|
||||
test('undo reverts a brush stroke', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
@@ -250,21 +218,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -284,20 +239,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
@@ -333,39 +285,3 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
wstest(
|
||||
'Will not use stale litegraph previews',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
)
|
||||
}
|
||||
|
||||
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
await expect.poll(getNodeOutput).toBe('test1.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const resolvableFile = { filename: 'example.png', type: 'input' }
|
||||
executionHelper.executed('', '1', { images: [resolvableFile] })
|
||||
await expect.poll(getNodeOutput).toBe('example.png')
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
await node.imagePreview.hover()
|
||||
await node.imagePreview
|
||||
.getByRole('button', { name: 'Edit or mask image' })
|
||||
.click()
|
||||
|
||||
// On previous versions, attempting to open the mask editor here would
|
||||
// incorrectly reference the non-existant test1.png
|
||||
// This causes the mask editor to throw in setup and not display
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
197
browser_tests/tests/missingMediaAssetUnion.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
// BE-933 / BE-934 add `file_path` (and BE-933 also `display_name`) to the asset
|
||||
// wire shape. `@comfyorg/ingest-types` is not yet regenerated from the updated
|
||||
// OpenAPI (tracked under BE-932); extend the local type so mocks can carry the
|
||||
// post-BE field without an `any` cast.
|
||||
type PostBEAsset = Asset & {
|
||||
file_path?: string | null
|
||||
display_name?: string | null
|
||||
}
|
||||
|
||||
const WORKFLOW_WIDGET_VALUE = 'fe746_photo.png'
|
||||
|
||||
async function mockAssetListing(
|
||||
page: Page,
|
||||
assets: PostBEAsset[]
|
||||
): Promise<void> {
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const response: ListAssetsResponse = {
|
||||
assets: assets as Asset[],
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mockAssetListingFailure(
|
||||
page: Page,
|
||||
status: number
|
||||
): Promise<void> {
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: `forced ${status} for FE-746 test` })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Missing media detection — file_path union (FE-746)',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
})
|
||||
|
||||
test('does not surface missing media when a post-BE asset emits file_path that diverges from the workflow widget value (Case B regression)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// BE-933 / BE-934 post-deploy shape: asset emits a namespace-rooted
|
||||
// file_path that differs from the bare `name` the user originally chose.
|
||||
// The workflow widget value (`fe746_photo.png`) predates the rollout, so
|
||||
// it must still match via the `name` arm of the detection-key union.
|
||||
// Case A (file_path-only early return) would mark this as missing.
|
||||
await mockAssetListing(comfyPage.page, [
|
||||
{
|
||||
id: 'fe746-asset-1',
|
||||
name: WORKFLOW_WIDGET_VALUE,
|
||||
asset_hash: 'blake3:fe7460000000000000000000000000000',
|
||||
file_path: 'input/sub/fe746_photo.png',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-22T00:00:00Z',
|
||||
updated_at: '2026-05-22T00:00:00Z',
|
||||
last_access_time: '2026-05-22T00:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
|
||||
})
|
||||
|
||||
test('matches via legacy `name` fallback when the asset has no file_path (BE-933 hash-only registration)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// BE-933 hash-only null case: asset registered via POST /assets/from-hash
|
||||
// has no on-disk path, so `file_path` (and `display_name`) come back null.
|
||||
// Detection must still succeed via the legacy `name` arm.
|
||||
await mockAssetListing(comfyPage.page, [
|
||||
{
|
||||
id: 'fe746-asset-hash-only',
|
||||
name: WORKFLOW_WIDGET_VALUE,
|
||||
asset_hash: 'blake3:fe7460000000000000000000000000001',
|
||||
file_path: null,
|
||||
display_name: null,
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-22T00:00:00Z',
|
||||
updated_at: '2026-05-22T00:00:00Z',
|
||||
last_access_time: '2026-05-22T00:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
|
||||
})
|
||||
|
||||
test('surfaces missing media when no asset in the listing covers the widget value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Sanity: with the union still in place, an asset listing that does not
|
||||
// include the widget value via any key (file_path / asset_hash / name)
|
||||
// must still report missing. Guards against accidental "match
|
||||
// everything" regressions when the early-return was removed.
|
||||
await mockAssetListing(comfyPage.page, [
|
||||
{
|
||||
id: 'fe746-unrelated-asset',
|
||||
name: 'unrelated.png',
|
||||
asset_hash: 'blake3:fe7460000000000000000000000000002',
|
||||
file_path: 'input/unrelated.png',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-22T00:00:00Z',
|
||||
updated_at: '2026-05-22T00:00:00Z',
|
||||
last_access_time: '2026-05-22T00:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaNames(comfyPage))
|
||||
.toContain(WORKFLOW_WIDGET_VALUE)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('soft-degrades when /api/assets fails so verification does not deadlock pending candidates', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Promise.allSettled + per-branch soft-degrade (Finding 2): when the
|
||||
// input-asset oracle fails (pre-BE-786 OSS without /api/assets, partial
|
||||
// BE-934 deploys, transient network errors), the verifier must finish
|
||||
// — marking the candidate missing — rather than leaving isMissing
|
||||
// stuck at undefined behind a silent toast.
|
||||
await mockAssetListingFailure(comfyPage.page, 500)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/fe746_load_image_bare_filename'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaNames(comfyPage))
|
||||
.toContain(WORKFLOW_WIDGET_VALUE)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -9,7 +9,7 @@ test.describe(
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
@@ -46,8 +46,15 @@ test.describe(
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 600 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
|
||||
@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -3,40 +3,36 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test(
|
||||
'Price badge displays on subgraphs',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
}
|
||||
)
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB |
@@ -115,42 +115,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should clear resolved missing model when Refresh is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await comfyPage.page.route(/\/object_info$/, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,23 @@ test.describe(
|
||||
'add-group-group-added.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.canvasOps.rightClick()
|
||||
await comfyPage.contextMenu.clickMenuItem(
|
||||
'Convert to Group Node (Deprecated)'
|
||||
)
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 103 KiB |
@@ -8,13 +8,14 @@ import type {
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's media-type filter menu only renders in cloud mode
|
||||
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
|
||||
// We tag tests `@cloud` so they run against the cloud Playwright project,
|
||||
// and register both `/api/assets` and `/api/jobs` route handlers as auto
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
// Post-FE-732 the media-type filter menu renders unconditionally on both
|
||||
// Cloud and OSS builds. These tests keep the `@cloud` tag because the
|
||||
// `/api/jobs` dependency is still cloud-only; once OSS exposes equivalent
|
||||
// jobs data we can drop the tag. Auto fixtures register `/api/assets` and
|
||||
// `/api/jobs` route handlers — Playwright runs auto fixtures before the
|
||||
// `comfyPage` fixture's internal `setup()`, so the page first-loads with
|
||||
// mocks already in place. See cloud-asset-default.spec.ts for the same
|
||||
// pattern.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ import type {
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// The assets sidebar's sort options live inside the settings popover and are
|
||||
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
|
||||
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
|
||||
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
|
||||
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
|
||||
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
|
||||
// page first-loads with mocks already in place.
|
||||
// Post-FE-732 the sort options inside the settings popover render
|
||||
// unconditionally on both Cloud and OSS builds. These tests keep the
|
||||
// `@cloud` tag because the `/api/jobs` dependency (used by the generation-
|
||||
// time sort) is still cloud-only; once OSS exposes equivalent jobs data we
|
||||
// can drop the tag. Auto fixtures register `/api/assets`, `/api/jobs`, and
|
||||
// `/internal/files/input` route handlers — Playwright runs auto fixtures
|
||||
// before the `comfyPage` fixture's internal `setup()`, so the page first-
|
||||
// loads with mocks already in place.
|
||||
|
||||
// Three jobs whose `(create_time, duration)` axes are intentionally
|
||||
// misaligned so newest/oldest and longest/fastest sorts produce *different*
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import {
|
||||
STABLE_INPUT_IMAGE,
|
||||
STABLE_INPUT_IMAGE_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
@@ -708,6 +712,30 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Footer shows delete button when input assets selected (FE-732)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Pre-FE-732 the input-tab Delete footer button was gated by isCloud and
|
||||
// hidden on OSS builds. Post-FE-732 it must render in both modes. This
|
||||
// test runs on the default chromium project — i.e. the OSS build — and
|
||||
// asserts the gate is gone.
|
||||
await comfyPage.assets.mockCloudAssets({
|
||||
assets: [STABLE_INPUT_IMAGE, STABLE_INPUT_IMAGE_2],
|
||||
total: 2,
|
||||
has_more: false
|
||||
})
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MOCK_FOLDERS: Record<string, string[]> = {
|
||||
checkpoints: [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVision_v51.safetensors'
|
||||
],
|
||||
loras: ['detail_tweaker_xl.safetensors', 'add_brightness.safetensors'],
|
||||
vae: ['sdxl_vae.safetensors']
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Tab open/close
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Opens model library tab and shows tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows refresh and load all folders buttons', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.refreshButton).toBeVisible()
|
||||
await expect(tab.loadAllFoldersButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Folder display
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - folders', () => {
|
||||
// Mocks are set up before setup(), so app.ts's loadModelFolders()
|
||||
// call during initialization hits the mock and populates the store.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays model folders after opening tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('vae')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a folder loads and shows models', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Click the folder to expand it
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
|
||||
// Models should appear as leaf nodes
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a different folder shows its models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.getFolderByLabel('loras').click()
|
||||
|
||||
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Search
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Search filters models by filename', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
|
||||
// Wait for debounce (300ms) + load + render
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
|
||||
// Other models should not be visible
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Clearing search restores folder view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
|
||||
// Clear the search
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
// Folders should be visible again (collapsed)
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Expand a folder and verify models are present before searching
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.leafNodes).not.toHaveCount(0)
|
||||
|
||||
await tab.searchInput.fill('nonexistent_model_xyz')
|
||||
|
||||
// Wait for debounce, then verify no leaf nodes
|
||||
await expect.poll(() => tab.leafNodes.count()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. Refresh and load all
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - refresh', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Refresh button reloads folder list', async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors']
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
|
||||
// Update mock to include a new folder
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors'],
|
||||
loras: ['lora_b.safetensors']
|
||||
})
|
||||
|
||||
// Wait for the refresh request to complete
|
||||
const refreshRequest = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().endsWith('/experiment/models'),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
await tab.refreshButton.click()
|
||||
await refreshRequest
|
||||
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Load all folders button triggers loading all model data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Wait for a per-folder model files request triggered by load all
|
||||
const folderRequest = comfyPage.page.waitForRequest(
|
||||
(req) =>
|
||||
/\/api\/experiment\/models\/[^/]+$/.test(req.url()) &&
|
||||
req.method() === 'GET',
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
await tab.loadAllFoldersButton.click()
|
||||
await folderRequest
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Empty state
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - empty state', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty tree when no model folders exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -40,19 +40,49 @@ test.describe(
|
||||
)
|
||||
const [nodeId1, nodeId2] = nodeIds
|
||||
|
||||
const promotedTextarea = (nodeId: string) =>
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
// Enter first subgraph, set text widget value
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea1.fill('subgraph1_value')
|
||||
await expect(textarea1).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await promotedTextarea(nodeId1).fill('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
// Enter second subgraph, set text widget value
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea2.fill('subgraph2_value')
|
||||
await expect(textarea2).toHaveValue('subgraph2_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await promotedTextarea(nodeId2).fill('subgraph2_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
// Re-enter first subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea1Again).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
// Re-enter second subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea2Again).toHaveValue('subgraph2_value')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,43 +1,38 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
test.describe(
|
||||
'Cleanup Behavior After Promoted Source Removal',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Deleting the promoted source removes the exterior promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test('Deleting the promoted source removes the exterior DOM widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
|
||||
|
||||
@@ -34,43 +34,49 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const OUTER_NODE_ID = '4'
|
||||
const INNER_SUBGRAPH_NODE_ID = '3'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
await comfyExpect(async () => {
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(outerWidgets).toHaveCount(1)
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
const exposedTextWidget = outerNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
|
||||
const textWidgets = widgetValues.filter((w) =>
|
||||
w.name.startsWith('text')
|
||||
)
|
||||
comfyExpect(textWidgets).toHaveLength(2)
|
||||
|
||||
const innerNode = comfyPage.vueNodes.getNodeLocator(
|
||||
INNER_SUBGRAPH_NODE_ID
|
||||
)
|
||||
await comfyExpect(innerNode).toBeVisible()
|
||||
|
||||
const innerTextboxes = innerNode.getByRole('textbox')
|
||||
await comfyExpect(innerTextboxes).toHaveCount(2)
|
||||
const innerValues = await innerTextboxes.evaluateAll<
|
||||
string[],
|
||||
HTMLInputElement
|
||||
>((boxes) => boxes.map((b) => b.value))
|
||||
comfyExpect(innerValues).toContain('11111111111')
|
||||
comfyExpect(innerValues).toContain('22222222222')
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -90,6 +96,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
@@ -122,6 +129,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeAfter).toBeVisible()
|
||||
@@ -168,6 +176,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -201,6 +210,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -221,6 +231,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -239,6 +250,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -256,6 +268,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
@@ -266,6 +279,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const initialCount = await widgets.count()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
@@ -61,12 +61,15 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select the positive CLIPTextEncode node (id 6)
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = String(subgraphNode.id)
|
||||
// CLIPTextEncode is in the recommendedNodes list, so its text widget
|
||||
// should be promoted
|
||||
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
|
||||
})
|
||||
|
||||
@@ -75,6 +78,7 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -82,6 +86,7 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
String(subgraphNode.id),
|
||||
@@ -90,73 +95,88 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Widget Visibility in Vue Mode',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
|
||||
test('Promoted text widget is visible on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
const enterButton = subgraphVueNode.getByTestId(
|
||||
'subgraph-enter-button'
|
||||
)
|
||||
await expect(enterButton).toBeVisible()
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
|
||||
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
|
||||
test.fail(
|
||||
'Promoted and interior widgets stay in sync across navigation',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await promotedTextarea.fill(testContent)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
test.describe('Promoted Widget Reactivity', () => {
|
||||
test('Promoted and interior widgets stay in sync across navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const interiorTextarea = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
.first()
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveValue(updatedInteriorContent)
|
||||
}
|
||||
)
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||
@@ -175,6 +195,7 @@ test.describe(
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
@@ -183,8 +204,10 @@ test.describe(
|
||||
await promoteEntry.click()
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
// Navigate back to parent
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
|
||||
})
|
||||
|
||||
@@ -193,6 +216,7 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// First promote a canvas-rendered widget (KSampler "steps")
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
@@ -208,6 +232,7 @@ test.describe(
|
||||
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
// Wait for the context menu to close, confirming the action completed.
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
@@ -255,9 +280,11 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(clipNode).toBeVisible()
|
||||
@@ -290,6 +317,8 @@ test.describe(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
'5',
|
||||
@@ -302,6 +331,7 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -309,6 +339,7 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is a recommended node, so filename_prefix should be promoted
|
||||
const nodeId = String(subgraphNode.id)
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
@@ -325,6 +356,7 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
@@ -361,48 +393,56 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Nested Promoted Widget Disabled State',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
.toEqual(expect.arrayContaining(['string_a', 'value']))
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const disabledState = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('5')
|
||||
return (node?.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
disabled: !!w.computedDisabled
|
||||
}))
|
||||
})
|
||||
return disabledState.find((w) => w.name === 'string_a')?.disabled
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
|
||||
let editedTextarea = false
|
||||
const count = await textareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = textareas.nth(i)
|
||||
const wrapper = textarea.locator('..')
|
||||
const opacity = await wrapper.evaluate(
|
||||
(el) => getComputedStyle(el).opacity
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
.toEqual(expect.arrayContaining(['string_a', 'value']))
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const linkedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'string_a',
|
||||
exact: true
|
||||
})
|
||||
await expect(linkedTextarea).toBeVisible()
|
||||
await expect(linkedTextarea).toBeDisabled()
|
||||
|
||||
const allTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(allTextareas.first()).toBeVisible()
|
||||
|
||||
let editedTextarea = false
|
||||
const count = await allTextareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = allTextareas.nth(i)
|
||||
if (await textarea.isEditable()) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
}
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
@@ -412,13 +452,16 @@ test.describe(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Verify promotions exist
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
|
||||
.toEqual(expect.arrayContaining([expect.anything()]))
|
||||
|
||||
// Delete the subgraph node
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
|
||||
// Node no longer exists, so promoted widgets should be gone
|
||||
await expect.poll(() => subgraphNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -477,13 +520,17 @@ test.describe(
|
||||
.toBeGreaterThan(0)
|
||||
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Remove the text input slot
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Widget count should be reduced
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
@@ -541,6 +588,7 @@ test.describe(
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
@@ -560,198 +608,216 @@ test.describe(
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Promote/Demote by Context Menu',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Properties panel operations',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: false
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
})
|
||||
|
||||
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'linked widgets are first by default'
|
||||
).toHaveText('steps')
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(
|
||||
editor.promotionItems.first(),
|
||||
'Swap widget order'
|
||||
).toContainText('cfg')
|
||||
|
||||
// FIXME: solve actual bug and remove the not
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'Linked widget is first on node'
|
||||
).not.toHaveText('cfg')
|
||||
})
|
||||
|
||||
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test(
|
||||
'Link already promoted widget',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
await expect(steps).toHaveCount(1)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
// FUTURE: Add test for re-ordering previews?
|
||||
|
||||
test(
|
||||
'Can promote multiple previews',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Linked widgets can not be demoted',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.ensureOpen(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
}
|
||||
)
|
||||
await editor.open(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
|
||||
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
@@ -29,125 +31,133 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Promotion DOM',
|
||||
{ tag: ['@subgraph', '@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
await parentTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget preserves content through subgraph enter/exit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
|
||||
})
|
||||
|
||||
const backToPromoted = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(backToPromoted).toBeVisible()
|
||||
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
test('Promoted text widget is removed when subgraph node is deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNodeRef.delete()
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
})
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
await expect(visibleWidgets).toHaveCount(2)
|
||||
const parentCount = await visibleWidgets.count()
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(promotedTextareas).toHaveCount(2)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
|
||||
const interiorTextareas = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox')
|
||||
await expect(interiorTextareas).toHaveCount(2)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,87 +14,6 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
|
||||
'subgraphs/subgraph-primitive-fanout-multi-host'
|
||||
const UNRESOLVABLE_PROXY_WORKFLOW =
|
||||
'subgraphs/subgraph-unresolvable-proxy-widget'
|
||||
|
||||
interface HostWidgetSnapshot {
|
||||
name: string
|
||||
sourceNodeId: string | null
|
||||
sourceWidgetName: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface PrimitiveFanoutSnapshot {
|
||||
hostWidgetNames: string[]
|
||||
hostWidgetValues: HostWidgetSnapshot[]
|
||||
interiorWidgetValues: unknown[]
|
||||
primitiveOutputLinks: unknown
|
||||
primitiveOriginLinkCount: number
|
||||
serializedProperties: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
|
||||
const [primitiveNode] = hostNode.subgraph.findNodesByType(
|
||||
'PrimitiveNode',
|
||||
[]
|
||||
)
|
||||
const primitiveOriginLinkCount = [
|
||||
...hostNode.subgraph._links.values()
|
||||
].filter((link) => link.origin_id === primitiveNode?.id).length
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
|
||||
return {
|
||||
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
|
||||
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
|
||||
name: widget.name,
|
||||
sourceNodeId:
|
||||
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
|
||||
? widget.sourceNodeId
|
||||
: null,
|
||||
sourceWidgetName:
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
? widget.sourceWidgetName
|
||||
: null,
|
||||
value: widget.value
|
||||
})),
|
||||
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
|
||||
(node.widgets ?? []).map((widget) => widget.value)
|
||||
),
|
||||
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const node = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
return node?.properties ?? {}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -122,173 +41,23 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test(
|
||||
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(beforeReload).toHaveCount(1)
|
||||
await expect(beforeReload).toBeVisible()
|
||||
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(beforeReload.hostWidgetNames).toContain('value')
|
||||
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgets'
|
||||
)
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgetErrorQuarantine'
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
|
||||
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(afterReload.interiorWidgetValues).toEqual(
|
||||
beforeReload.interiorWidgetValues
|
||||
)
|
||||
expect(
|
||||
afterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe(
|
||||
beforeReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
)
|
||||
expect(afterReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(afterReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgets'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Multiple SubgraphNode hosts keep independent migrated widget values',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
|
||||
)
|
||||
|
||||
const expectHostHasIndependentValues = async (
|
||||
hostId: string,
|
||||
stringValue: string,
|
||||
intValue: string
|
||||
) => {
|
||||
const host = comfyPage.vueNodes.getNodeLocator(hostId)
|
||||
const widgets = host.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
|
||||
stringValue
|
||||
)
|
||||
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
|
||||
intValue
|
||||
)
|
||||
}
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Nested preview exposures render through serialized chain resolution',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
test.setTimeout(45_000)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
|
||||
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'8'
|
||||
)
|
||||
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(nestedHostProperties.previewExposures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: '6',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
|
||||
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
await expect(nestedSubgraphNode).toBeVisible()
|
||||
await expect(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
|
||||
.toContain('$$canvas-image-preview')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy unresolvable proxy entry is omitted and quarantined on save',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
|
||||
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host).toBeVisible()
|
||||
await expect(host.getByText('missing_widget')).toHaveCount(0)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
|
||||
.not.toContain('missing_widget')
|
||||
|
||||
const serializedProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'2'
|
||||
)
|
||||
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'missing_widget'],
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 'quarantined-host-value'
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Promoted widget remains usable after serialize and reload',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const beforeReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(beforeReload).toBeVisible()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(afterReload).toBeVisible()
|
||||
}
|
||||
)
|
||||
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(afterReload).toHaveCount(1)
|
||||
await expect(afterReload).toBeVisible()
|
||||
})
|
||||
|
||||
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
|
||||
comfyPage
|
||||
@@ -644,10 +413,39 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -668,6 +466,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
@@ -683,14 +482,19 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
await expect(widgetRows.first()).not.toContainText('6: 3:')
|
||||
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,29 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag on a Vue node pans canvas',
|
||||
{ tag: ['@canvas'] },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const offsetBefore = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await comfyMouse.middleDragFromCenter(
|
||||
node,
|
||||
{ x: 140, y: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBefore)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'@mobile Can pan with touch',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -507,6 +507,25 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
.toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('should convert to group node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'workflow>TestGroupNode'
|
||||
)
|
||||
return groupNodes.length
|
||||
})
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
test('should convert selected nodes to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -111,10 +111,12 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
|
||||
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(
|
||||
firstSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(
|
||||
secondSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |