Compare commits
55 Commits
jaewon/m1-
...
rizumu/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40780cac6f | ||
|
|
75863e7f9a | ||
|
|
4d205deb09 | ||
|
|
942edb069a | ||
|
|
95317306db | ||
|
|
452729aa7d | ||
|
|
260fe4db34 | ||
|
|
c3dc7f45d4 | ||
|
|
c2ef961834 | ||
|
|
78c16368d7 | ||
|
|
8206022982 | ||
|
|
5f2b2f2e87 | ||
|
|
a931acadd3 | ||
|
|
db6b7a315c | ||
|
|
b89940134f | ||
|
|
7ac1cbbd53 | ||
|
|
7caba4408d | ||
|
|
fa4ffe3254 | ||
|
|
d05eadba97 | ||
|
|
0157b47024 | ||
|
|
7599c6a1ca | ||
|
|
876ed502c9 | ||
|
|
ff8a19f233 | ||
|
|
c638ad194b | ||
|
|
b166532b24 | ||
|
|
5a7b1d6a90 | ||
|
|
682bd14061 | ||
|
|
5b48bf67a9 | ||
|
|
bbaaa82125 | ||
|
|
601cec68b9 | ||
|
|
8d1a170136 | ||
|
|
08ee925811 | ||
|
|
fb5b4a62ba | ||
|
|
cb62604d21 | ||
|
|
d02c5d374f | ||
|
|
c0ef283a05 | ||
|
|
d405002127 | ||
|
|
abd233d10d | ||
|
|
e1049a99a3 | ||
|
|
3da6e1766e | ||
|
|
52830a9e73 | ||
|
|
1cd98e8ab2 | ||
|
|
654a7d6904 | ||
|
|
eeeacc9b03 | ||
|
|
f744a4f1f8 | ||
|
|
0588ca45b3 | ||
|
|
60ce0ee0c3 | ||
|
|
91d2df45a1 | ||
|
|
7b4fef5eca | ||
|
|
c703db5f6c | ||
|
|
3011d3a60c | ||
|
|
6e31ce77c6 | ||
|
|
551c595bbb | ||
|
|
ee286291d4 | ||
|
|
efb214efe7 |
@@ -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 -- run`
|
||||
# with no arg matchers 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`
|
||||
# with no path filter 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 -- run "${TEST_FILES[@]}"
|
||||
pnpm test:unit "${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 -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm test:unit <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,8 +19,5 @@ runs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp -r ./tools/devtools/* /ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
# 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 }}" &
|
||||
cd /ComfyUI && python3 main.py --cpu --multi-user --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,12 +45,8 @@ 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: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
run: 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.
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
5
.gitignore
vendored
@@ -19,6 +19,7 @@ yarn.lock
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.nx
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -89,10 +90,6 @@ 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,7 +2,6 @@
|
||||
"$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 **Nx** for build orchestration and task management
|
||||
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
|
||||
|
||||
## Package Manager
|
||||
|
||||
@@ -237,7 +237,6 @@ 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
|
||||
@@ -308,6 +307,20 @@ 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[nx serve hangs]
|
||||
B -->|Dev server stuck| C[pnpm dev 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 dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
@@ -41,11 +41,11 @@ flowchart TD
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
#### Q: `pnpm dev` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs on "nx serve"
|
||||
- Command hangs during Vite startup
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
@@ -65,7 +65,7 @@ flowchart TD
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
pnpm clean:all && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
@@ -73,7 +73,7 @@ flowchart TD
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- NX cache corruption
|
||||
- stale local build cache
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"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",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
@@ -33,88 +36,5 @@
|
||||
"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,88 +45,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/website/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
4
apps/website/public/favicon-dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
11
apps/website/public/favicon-light.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/website/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -1,14 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 615 B |
@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="max-w-9xl mx-auto 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="px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto 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="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="max-w-9xl mx-auto 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="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="max-w-9xl mx-auto 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="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="max-w-9xl mx-auto 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="px-4 py-24 md:px-20 md:py-40">
|
||||
<section class="max-w-9xl mx-auto 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
export type NavDropdownItem = {
|
||||
type NavDropdownItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: string
|
||||
|
||||
@@ -46,7 +46,9 @@ const cards = excludeProduct
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto 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,7 +3,6 @@ const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'EA',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
|
||||
@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-16 lg:px-16 lg:py-24">
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
|
||||
<!-- Scrollable track -->
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
|
||||
class="flex snap-x snap-mandatory scrollbar-none 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="px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto 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="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
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"
|
||||
>
|
||||
<a
|
||||
v-for="story in customerStories"
|
||||
|
||||
@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-16 lg:px-20 lg:py-40">
|
||||
<section class="max-w-9xl mx-auto 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="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
|
||||
@@ -223,7 +223,10 @@ while (idx < items.length) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
|
||||
<section
|
||||
data-testid="gallery-grid"
|
||||
class="max-w-9xl mx-auto 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,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
|
||||
<section
|
||||
class="max-w-9xl mx-auto 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 flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
|
||||
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"
|
||||
>
|
||||
<!-- Node rows -->
|
||||
<div
|
||||
|
||||
@@ -12,7 +12,9 @@ const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto 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,7 +36,9 @@ const steps = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto 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="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
ref="logoContainer"
|
||||
|
||||
@@ -55,7 +55,10 @@ watch(activeIndex, (current, previous) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="max-w-9xl mx-auto 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="px-4 py-16 lg:px-20 lg:py-14">
|
||||
<section class="max-w-9xl mx-auto 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="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
|
||||
<div class="mb-6 flex scrollbar-none 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="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<section class="max-w-9xl mx-auto 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="px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<section class="max-w-9xl mx-auto 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 mx-4 mt-4 mb-24 p-2 lg:mx-20 lg:mt-8 lg:mb-40"
|
||||
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"
|
||||
>
|
||||
<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="px-4 py-24 lg:px-20">
|
||||
<section class="max-w-9xl mx-auto 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="px-4 py-24 lg:px-20 lg:py-40">
|
||||
<section class="max-w-9xl mx-auto 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,7 +77,9 @@ function getCardClass(layoutClass: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-primary-comfy-ink px-4 py-24 lg:px-20 lg:py-40">
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto 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="px-4 py-24 lg:px-20">
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
|
||||
<SectionHeader>
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
|
||||
@@ -22,7 +22,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-24 lg:px-20">
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
|
||||
<SectionHeader>
|
||||
{{ heading }}
|
||||
<template #subtitle>
|
||||
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
|
||||
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"
|
||||
>
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
@@ -71,7 +71,20 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<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="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedRole {
|
||||
interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedNode {
|
||||
interface DroppedNode {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -213,7 +213,8 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment)
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment),
|
||||
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ export class SubgraphEditor {
|
||||
)
|
||||
}
|
||||
|
||||
async open(subgraphNode: Locator) {
|
||||
async ensureOpen(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()
|
||||
@@ -69,7 +70,7 @@ export class SubgraphEditor {
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.open(subgraphNode)
|
||||
await this.ensureOpen(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
|
||||
@@ -6,8 +6,9 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
*
|
||||
* Widgets are located by their key (format: "nodeId:widgetName") via the
|
||||
* `data-widget-key` attribute on each widget item.
|
||||
* Widgets are located by `nodeId:widgetName` suffix against the
|
||||
* `data-widget-key` attribute, which carries the canonical
|
||||
* `graphId:nodeId:widgetName` WidgetEntityId.
|
||||
*/
|
||||
export class AppModeWidgetHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -20,9 +21,9 @@ export class AppModeWidgetHelper {
|
||||
return this.comfyPage.appMode.linearWidgets
|
||||
}
|
||||
|
||||
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
|
||||
/** Get a widget item container by its `nodeId:widgetName` suffix. */
|
||||
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,22 +119,9 @@ export class BuilderSelectHelper {
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
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()
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
// 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,6 +51,20 @@ 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,16 +216,6 @@ 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,6 +14,8 @@ 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
|
||||
@@ -423,39 +425,9 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
|
||||
> {
|
||||
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))
|
||||
})
|
||||
return getAllHostPromotedWidgets(this.comfyPage)
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
|
||||
@@ -62,12 +62,39 @@ export class WorkflowHelper {
|
||||
|
||||
async waitForDraftPersisted() {
|
||||
await this.comfyPage.page.waitForFunction(() =>
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
Object.keys(localStorage).some((key) =>
|
||||
key.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
|
||||
|
||||
@@ -128,7 +128,8 @@ export const TestIds = {
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image',
|
||||
slotConnectionDot: 'slot-connection-dot'
|
||||
slotConnectionDot: 'slot-connection-dot',
|
||||
imageGrid: 'image-grid'
|
||||
},
|
||||
selectionToolbox: {
|
||||
root: 'selection-toolbox',
|
||||
@@ -152,6 +153,7 @@ 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,19 +511,7 @@ export class NodeReference {
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
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]
|
||||
await this.comfyPage.contextMenu.clickMenuItem(optionText)
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
|
||||
@@ -1,48 +1,77 @@
|
||||
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 isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
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 {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
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'
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
// 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]]
|
||||
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
|
||||
}
|
||||
return []
|
||||
})
|
||||
}, nodeId)
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
@@ -78,12 +107,29 @@ export async function getPromotedWidgetCountByName(
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
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
|
||||
)
|
||||
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 entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
interface BoxOrigin {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
public readonly header: Locator
|
||||
@@ -15,7 +22,9 @@ export class VueNodeFixture {
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
public readonly imageGrid: Locator
|
||||
public readonly content: Locator
|
||||
public readonly resize: { bottomRight: Locator }
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -28,7 +37,10 @@ export class VueNodeFixture {
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
|
||||
this.content = locator.locator('.lg-node-content')
|
||||
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
|
||||
this.resize = { bottomRight }
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -77,4 +89,100 @@ export class VueNodeFixture {
|
||||
: slotLocators.filter({ has: nameOrLocator })
|
||||
return filteredLocator.getByTestId('slot-dot').locator('..')
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the node header to select it, then return its bounding box.
|
||||
* Throws if the node is not laid out because geometry-sensitive tests
|
||||
* cannot proceed without coordinates.
|
||||
*/
|
||||
async selectAndGetBox(): Promise<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}> {
|
||||
await this.header.click()
|
||||
const box = await this.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error('Node bounding box not found after select')
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert this node's top-left origin stays within `precision` decimal
|
||||
* places of `expected`. Wraps the polled bounding-box pattern that drift
|
||||
* tests repeat for both axes.
|
||||
*/
|
||||
async expectAnchoredAt(
|
||||
expected: BoxOrigin,
|
||||
{ precision = 1 }: { precision?: number } = {}
|
||||
): Promise<void> {
|
||||
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
|
||||
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
|
||||
}
|
||||
|
||||
/** Poll the node's left/x edge for use with `expect.poll`. */
|
||||
pollLeftEdge = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.x ?? null
|
||||
|
||||
/** Poll the node's top/y edge for use with `expect.poll`. */
|
||||
pollTopEdge = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.y ?? null
|
||||
|
||||
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
|
||||
pollRightEdge = async (): Promise<number | null> => {
|
||||
const b = await this.boundingBox()
|
||||
return b ? b.x + b.width : null
|
||||
}
|
||||
|
||||
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
|
||||
pollBottomEdge = async (): Promise<number | null> => {
|
||||
const b = await this.boundingBox()
|
||||
return b ? b.y + b.height : null
|
||||
}
|
||||
|
||||
/** Poll the node's width for use with `expect.poll`. */
|
||||
pollWidth = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.width ?? null
|
||||
|
||||
/** Poll the node's height for use with `expect.poll`. */
|
||||
pollHeight = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.height ?? null
|
||||
|
||||
/** Locator for the resize handle at the given corner, scoped to this node. */
|
||||
getResizeHandle(corner: CompassCorners): Locator {
|
||||
return this.root.locator(`[data-corner="${corner}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
|
||||
* Uses `hover()` to land the pointer on the handle with Playwright's
|
||||
* actionability checks before starting the mouse sequence, which protects
|
||||
* against occluding overlays and subpixel hit-test misses.
|
||||
*/
|
||||
async resizeFromCorner(
|
||||
corner: CompassCorners,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): Promise<void> {
|
||||
const handle = this.getResizeHandle(corner)
|
||||
await handle.hover()
|
||||
const box = await handle.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(
|
||||
`Resize handle for corner "${corner}" has no bounding box`
|
||||
)
|
||||
}
|
||||
|
||||
const page = this.locator.page()
|
||||
const startX = box.x + box.width / 2
|
||||
const startY = box.y + box.height / 2
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX + deltaX, startY + deltaY, {
|
||||
steps: 5
|
||||
})
|
||||
await page.mouse.up()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
* FE-230: Deleting an asset must clear the Load Image node preview, widget
|
||||
* value, and mark the workflow dirty.
|
||||
*
|
||||
* 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.
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
* The cloud project is required because input-asset deletion is gated on
|
||||
* `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`).
|
||||
*/
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
@@ -121,88 +119,81 @@ 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 (cloud)',
|
||||
'FE-230 asset delete clears Load Image preview',
|
||||
{ tag: '@cloud' },
|
||||
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')
|
||||
|
||||
baseTest.describe(
|
||||
'FE-230 asset delete clears Load Image preview (oss)',
|
||||
{ tag: '@oss' },
|
||||
registerDeleteFlowTest
|
||||
// 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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: 50 }
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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: 50 }
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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,6 +157,13 @@ 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,9 +7,14 @@ 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)
|
||||
@@ -18,22 +23,19 @@ 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.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
|
||||
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
@@ -41,9 +43,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Add group node from node library sidebar
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getNode(groupNodeName).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getNode(GROUP_NODE_NAME).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
@@ -52,9 +53,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
@@ -63,13 +64,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([groupNodeBookmarkName])
|
||||
.toEqual([GROUP_NODE_BOOKMARK])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
|
||||
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
|
||||
|
||||
// Unbookmark the node
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.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(groupNodeCategory).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
@@ -96,72 +96,57 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
).toBeVisible()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
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' })
|
||||
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' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${groupNodeName}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
|
||||
.toHaveLength(2)
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
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 expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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)
|
||||
}
|
||||
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 group1 = await makeGroup(
|
||||
'g1',
|
||||
'CLIPTextEncode',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
|
||||
|
||||
const manage1 = await group1.manageGroupNode()
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
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')
|
||||
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
|
||||
await manage.close()
|
||||
await expect(manage.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
@@ -201,42 +186,6 @@ 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
|
||||
}) => {
|
||||
@@ -249,11 +198,6 @@ 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) => {
|
||||
@@ -282,10 +226,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
@@ -299,10 +243,7 @@ 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()
|
||||
@@ -342,24 +283,6 @@ 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,6 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } 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(
|
||||
@@ -218,8 +222,21 @@ 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({
|
||||
@@ -239,17 +256,20 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
expect(
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
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 })
|
||||
)
|
||||
@@ -285,3 +305,39 @@ 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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
@@ -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: 520 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
@@ -46,15 +46,8 @@ test.describe(
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 600 })
|
||||
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,6 +309,50 @@ 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,36 +3,40 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
test(
|
||||
'Price badge displays on subgraphs',
|
||||
{ tag: ['@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: 47 KiB After Width: | Height: | Size: 44 KiB |
@@ -115,5 +115,42 @@ 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,23 +35,6 @@ 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: 102 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
@@ -8,14 +8,13 @@ import type {
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
|
||||
@@ -8,14 +8,13 @@ import type {
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
// Three jobs whose `(create_time, duration)` axes are intentionally
|
||||
// misaligned so newest/oldest and longest/fastest sorts produce *different*
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
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,
|
||||
@@ -712,30 +708,6 @@ 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()
|
||||
|
||||
236
browser_tests/tests/sidebar/modelLibrary.spec.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
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,49 +40,19 @@ test.describe(
|
||||
)
|
||||
const [nodeId1, nodeId2] = nodeIds
|
||||
|
||||
// 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()
|
||||
const promotedTextarea = (nodeId: string) =>
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
|
||||
// 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(nodeId1).fill('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_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 promotedTextarea(nodeId2).fill('subgraph2_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')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function waitForRootCanvasReady(page: Page) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app?.rootGraph?.id ?? '',
|
||||
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
|
||||
}))
|
||||
return state.rootId !== '' && state.canvasGraphId === state.rootId
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectCanvasOnRootGraph(page: Page) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
rootId: expect.any(String),
|
||||
canvasGraphId: expect.stringMatching(/.+/),
|
||||
hash: expect.stringMatching(/^#.+/)
|
||||
})
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
expect(state.canvasGraphId).toBe(state.rootId)
|
||||
expect(state.hash).toBe(`#${state.rootId}`)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph hash validation (FE-559)',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
const phantomId = '11111111-1111-4111-8111-111111111111'
|
||||
expect(phantomId).not.toBe(rootId)
|
||||
|
||||
await comfyPage.page.evaluate((hash) => {
|
||||
window.location.hash = hash
|
||||
}, `#${phantomId}`)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
|
||||
test('redirects URL and canvas to root when hash is malformed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.location.hash = '#not-a-valid-uuid'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,38 +1,43 @@
|
||||
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', () => {
|
||||
test('Deleting the promoted source removes the exterior DOM widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
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'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
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.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
|
||||
|
||||
@@ -34,49 +34,43 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget'] },
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
const OUTER_NODE_ID = '4'
|
||||
const INNER_SUBGRAPH_NODE_ID = '3'
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
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 outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(outerWidgets).toHaveCount(1)
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
const exposedTextWidget = outerNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
|
||||
|
||||
const textWidgets = widgetValues.filter((w) =>
|
||||
w.name.startsWith('text')
|
||||
)
|
||||
comfyExpect(textWidgets).toHaveLength(2)
|
||||
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
}).toPass({ timeout: 5_000 })
|
||||
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')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -96,7 +90,6 @@ 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()
|
||||
@@ -129,7 +122,6 @@ 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()
|
||||
@@ -176,7 +168,6 @@ 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()
|
||||
@@ -210,7 +201,6 @@ 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()
|
||||
@@ -231,7 +221,6 @@ 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()
|
||||
@@ -250,7 +239,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -268,7 +256,6 @@ 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)
|
||||
@@ -279,7 +266,6 @@ 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,15 +61,12 @@ 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')
|
||||
})
|
||||
|
||||
@@ -78,7 +75,6 @@ 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()
|
||||
|
||||
@@ -86,7 +82,6 @@ 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),
|
||||
@@ -95,88 +90,73 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
)
|
||||
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'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
const enterButton = subgraphVueNode.getByTestId(
|
||||
'subgraph-enter-button'
|
||||
)
|
||||
await expect(enterButton).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 nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).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 widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
})
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await promotedTextarea.fill(testContent)
|
||||
|
||||
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'
|
||||
)
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
const interiorTextarea = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
.first()
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
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)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveValue(updatedInteriorContent)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||
@@ -195,7 +175,6 @@ 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/ })
|
||||
@@ -204,10 +183,8 @@ 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)
|
||||
})
|
||||
|
||||
@@ -216,7 +193,6 @@ 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()
|
||||
|
||||
@@ -232,7 +208,6 @@ 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()
|
||||
@@ -280,11 +255,9 @@ 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()
|
||||
@@ -317,8 +290,6 @@ 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',
|
||||
@@ -331,7 +302,6 @@ 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()
|
||||
|
||||
@@ -339,7 +309,6 @@ 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,
|
||||
@@ -356,7 +325,6 @@ 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()
|
||||
@@ -393,56 +361,48 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
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'
|
||||
)
|
||||
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
})
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
@@ -452,16 +412,13 @@ 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)
|
||||
})
|
||||
|
||||
@@ -520,17 +477,13 @@ 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)
|
||||
@@ -588,7 +541,6 @@ 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)'
|
||||
@@ -608,216 +560,198 @@ test.describe(
|
||||
}
|
||||
)
|
||||
|
||||
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')
|
||||
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')
|
||||
|
||||
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 @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',
|
||||
{ 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')
|
||||
|
||||
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('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')
|
||||
|
||||
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).toHaveCount(1)
|
||||
})
|
||||
|
||||
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',
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
// FUTURE: Add test for re-ordering previews?
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
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 expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
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('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('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.unpromoteWidget(ksampler.root, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
})
|
||||
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 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')
|
||||
|
||||
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).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
|
||||
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('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(subgraph.content).toHaveCount(1)
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,8 +5,6 @@ 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) {
|
||||
@@ -31,133 +29,125 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
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 ({
|
||||
test.describe(
|
||||
'Subgraph Promotion DOM',
|
||||
{ tag: ['@subgraph', '@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
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')
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
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)
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
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'
|
||||
)
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
|
||||
})
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
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'
|
||||
)
|
||||
const backToPromoted = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(backToPromoted).toBeVisible()
|
||||
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
test('Promoted text widget is removed when subgraph node is deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
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')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNodeRef.delete()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
})
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
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 expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
await expect(visibleWidgets).toHaveCount(2)
|
||||
const parentCount = await visibleWidgets.count()
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(promotedTextareas).toHaveCount(2)
|
||||
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
const interiorTextareas = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox')
|
||||
await expect(interiorTextareas).toHaveCount(2)
|
||||
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||