Compare commits
1 Commits
drjkl/subg
...
fix-masked
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c671eb1bce |
@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
6
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -45,8 +45,12 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: pnpm build:cloud
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
|
||||
- name: Run Claude PR Review
|
||||
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
|
||||
|
||||
2
.github/workflows/release-draft-create.yaml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
pnpm build
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
6
.github/workflows/weekly-docs-check.yaml
vendored
@@ -40,11 +40,11 @@ jobs:
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
# Check if packages are already available locally
|
||||
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
echo "Installing TypeScript and Vue compiler globally..."
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
else
|
||||
echo "TypeScript and Vue compiler already available globally"
|
||||
echo "TypeScript and Vue compiler already available locally"
|
||||
fi
|
||||
|
||||
- name: Run Claude Documentation Review
|
||||
|
||||
4
.gitignore
vendored
@@ -89,6 +89,10 @@ storybook-static
|
||||
# MCP Servers
|
||||
.playwright-mcp/*
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
|
||||
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
".i18nrc.cjs",
|
||||
".nx/*",
|
||||
"**/vite.config.*.timestamp*",
|
||||
"**/vitest.config.*.timestamp*",
|
||||
"components.d.ts",
|
||||
|
||||
17
AGENTS.md
@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
|
||||
The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Package Manager
|
||||
|
||||
@@ -237,6 +237,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Architecture Decision Records
|
||||
@@ -307,20 +308,6 @@ When referencing Comfy-Org repos:
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER add multi-line block comments to justify trivial code changes
|
||||
- A one-line fix does not need a three-line comment explaining why
|
||||
- A guard clause that mirrors another file does not need a comment naming that file
|
||||
- A test setup line does not need a comment paraphrasing what the next line does
|
||||
- If the diff is small and obvious, the comment is noise — write the code and move on
|
||||
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
|
||||
- **Penance protocol when you catch yourself adding one of these comments:**
|
||||
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
|
||||
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
|
||||
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
|
||||
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
|
||||
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
|
||||
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
|
||||
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
|
||||
@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Having Issues?] --> B{What's the problem?}
|
||||
B -->|Dev server stuck| C[pnpm dev hangs]
|
||||
B -->|Dev server stuck| C[nx serve hangs]
|
||||
B -->|Build errors| D[Check build issues]
|
||||
B -->|Lint errors| Q[Check linting issues]
|
||||
B -->|Dependency issues| E[Package problems]
|
||||
@@ -23,7 +23,7 @@ flowchart TD
|
||||
G -->|No| H[Run: pnpm i]
|
||||
G -->|Still stuck| I[Run: pnpm clean]
|
||||
I --> J{Still stuck?}
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
@@ -41,11 +41,11 @@ flowchart TD
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` gets stuck and won't start
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs during Vite startup
|
||||
- Command hangs on "nx serve"
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
@@ -65,7 +65,7 @@ flowchart TD
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm clean:all && pnpm i
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
@@ -73,7 +73,7 @@ flowchart TD
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- stale local build cache
|
||||
- NX cache corruption
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
|
||||
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
|
||||
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
|
||||
"lint": "eslint src --cache",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
@@ -36,5 +33,88 @@
|
||||
"vite-plugin-html": "catalog:",
|
||||
"vite-plugin-vue-devtools": "catalog:",
|
||||
"vue-tsc": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:desktop",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite preview --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook dev -p 6007"
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook build -o dist/storybook"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/storybook"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "eslint src --cache"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,5 +45,88 @@
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:website",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro build"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro check"
|
||||
}
|
||||
},
|
||||
"test:unit": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run"
|
||||
}
|
||||
},
|
||||
"test:coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run --coverage"
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "playwright test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-22T00:07:48.353Z",
|
||||
"fetchedAt": "2026-05-12T16:10:34.114Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
@@ -36,14 +36,14 @@
|
||||
"id": "6a6d865eeb3c10a8",
|
||||
"title": "Senior Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
|
||||
},
|
||||
{
|
||||
"id": "1b4f7f1da9616e14",
|
||||
"title": "Senior Software Engineer, Backend Generalist",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
|
||||
},
|
||||
{
|
||||
@@ -71,14 +71,14 @@
|
||||
"id": "91604c4182a1bc3c",
|
||||
"title": "Software Engineer, Core ComfyUI Contributor",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
|
||||
},
|
||||
{
|
||||
"id": "a1dbc0576ab14034",
|
||||
"title": "Software Engineer, ComfyUI Desktop",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
|
||||
},
|
||||
{
|
||||
@@ -105,21 +105,21 @@
|
||||
"id": "23dd98cab77ff459",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Marketing",
|
||||
"location": "Remote",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
|
||||
},
|
||||
{
|
||||
"id": "a998b9fc973ff3c0",
|
||||
"title": "Creative Artist",
|
||||
"department": "Marketing",
|
||||
"location": "Remote",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
|
||||
},
|
||||
{
|
||||
"id": "3e730938026d6e70",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Marketing",
|
||||
"location": "Remote",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
|
||||
},
|
||||
{
|
||||
@@ -135,20 +135,6 @@
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
|
||||
},
|
||||
{
|
||||
"id": "e11f8b9e58dbea81",
|
||||
"title": "Creative Producer",
|
||||
"department": "Marketing",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
|
||||
},
|
||||
{
|
||||
"id": "6eac654593208ec3",
|
||||
"title": "Forward Deployed Creative Technologist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,8 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_REGISTRY_BASE_URL,
|
||||
fetchRegistryPacks,
|
||||
fetchRegistryPacksWithNodes
|
||||
fetchRegistryPacks
|
||||
} from './cloudNodes.registry'
|
||||
|
||||
function jsonResponse(
|
||||
@@ -143,315 +142,3 @@ describe('fetchRegistryPacks', () => {
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRegistryPacksWithNodes', () => {
|
||||
it('fetches pack metadata and comfy nodes for each pack', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
// Pack metadata request
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
|
||||
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Comfy nodes request
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
|
||||
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
|
||||
],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
const packData = result.get('comfyui-impact-pack')
|
||||
expect(packData).not.toBeNull()
|
||||
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
|
||||
expect(packData?.nodes).toHaveLength(2)
|
||||
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
|
||||
})
|
||||
|
||||
it('handles pagination for comfy nodes', async () => {
|
||||
let comfyNodesCallCount = 0
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'big-pack',
|
||||
name: 'Big Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
comfyNodesCallCount++
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
|
||||
if (page === 1) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{ comfy_node_name: 'Node1', category: 'cat1' },
|
||||
{ comfy_node_name: 'Node2', category: 'cat1' }
|
||||
],
|
||||
totalNumberOfPages: 2
|
||||
})
|
||||
} else {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
|
||||
totalNumberOfPages: 2
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(comfyNodesCallCount).toBe(2)
|
||||
const packData = result.get('big-pack')
|
||||
expect(packData?.nodes).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('returns null for packs without latest_version', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'no-version-pack',
|
||||
name: 'No Version Pack',
|
||||
latest_version: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(result.get('no-version-pack')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty nodes array when comfy-nodes request fails', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'failing-pack',
|
||||
name: 'Failing Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return new Response('Server error', { status: 500 })
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const packData = result.get('failing-pack')
|
||||
expect(packData).not.toBeNull()
|
||||
expect(packData?.pack.name).toBe('Failing Pack')
|
||||
expect(packData?.nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles null comfy_nodes in response', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'null-nodes-pack',
|
||||
name: 'Null Nodes Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: null,
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const packData = result.get('null-nodes-pack')
|
||||
expect(packData?.nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fetches nodes for multiple packs in parallel', async () => {
|
||||
const packIds = ['pack-a', 'pack-b', 'pack-c']
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
const requestedIds = url.searchParams.getAll('node_id')
|
||||
return jsonResponse({
|
||||
nodes: requestedIds.map((id) => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
latest_version: { version: '1.0.0' }
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{ comfy_node_name: `${packId}-node`, category: 'test' }
|
||||
],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(packIds, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(result.size).toBe(3)
|
||||
for (const packId of packIds) {
|
||||
const packData = result.get(packId)
|
||||
expect(packData).not.toBeNull()
|
||||
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
|
||||
}
|
||||
})
|
||||
|
||||
it('retries comfy-nodes fetch once on failure', async () => {
|
||||
let comfyNodesAttempts = 0
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'retry-pack',
|
||||
name: 'Retry Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
comfyNodesAttempts++
|
||||
if (comfyNodesAttempts === 1) {
|
||||
return new Response('Server error', { status: 500 })
|
||||
}
|
||||
return jsonResponse({
|
||||
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(comfyNodesAttempts).toBe(2)
|
||||
const packData = result.get('retry-pack')
|
||||
expect(packData?.nodes).toHaveLength(1)
|
||||
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
|
||||
})
|
||||
|
||||
it('normalizes null boolean fields in comfy nodes', async () => {
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
if (url.pathname === '/nodes') {
|
||||
return jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'bool-pack',
|
||||
name: 'Bool Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname.includes('/comfy-nodes')) {
|
||||
return jsonResponse({
|
||||
comfy_nodes: [
|
||||
{
|
||||
comfy_node_name: 'TestNode',
|
||||
category: 'test',
|
||||
deprecated: null,
|
||||
experimental: null
|
||||
}
|
||||
],
|
||||
totalNumberOfPages: 1
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const packData = result.get('bool-pack')
|
||||
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
|
||||
expect(packData?.nodes[0]?.experimental).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { components } from '@comfyorg/registry-types'
|
||||
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 5_000
|
||||
const BATCH_SIZE = 50
|
||||
const COMFY_NODES_PAGE_SIZE = 500
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
export type RegistryComfyNode = components['schemas']['ComfyNode']
|
||||
|
||||
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
|
||||
return value ?? undefined
|
||||
@@ -60,29 +58,6 @@ const RegistryListResponseSchema = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RegistryComfyNodeSchema = z
|
||||
.object({
|
||||
comfy_node_name: optionalString,
|
||||
category: optionalString,
|
||||
description: optionalString,
|
||||
deprecated: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((v) => v ?? undefined),
|
||||
experimental: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((v) => v ?? undefined)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RegistryComfyNodesResponseSchema = z
|
||||
.object({
|
||||
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
|
||||
totalNumberOfPages: z.number().nullish()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
interface FetchRegistryOptions {
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
@@ -147,142 +122,6 @@ export async function fetchRegistryPacks(
|
||||
return resolved
|
||||
}
|
||||
|
||||
export interface RegistryPackWithNodes {
|
||||
pack: RegistryPack
|
||||
nodes: RegistryComfyNode[]
|
||||
}
|
||||
|
||||
export async function fetchRegistryPacksWithNodes(
|
||||
packIds: readonly string[],
|
||||
options: FetchRegistryOptions = {}
|
||||
): Promise<Map<string, RegistryPackWithNodes | null>> {
|
||||
const packs = await fetchRegistryPacks(packIds, options)
|
||||
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
|
||||
const timeoutMs = clampTimeoutMs(options.timeoutMs)
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...packs.entries()].map(
|
||||
async ([packId, pack]): Promise<
|
||||
[string, RegistryPackWithNodes | null]
|
||||
> => {
|
||||
if (!pack?.latest_version?.version) {
|
||||
return [packId, null]
|
||||
}
|
||||
|
||||
const nodes = await fetchComfyNodesForPack(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
pack.latest_version.version,
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
return [packId, { pack, nodes }]
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return new Map(entries)
|
||||
}
|
||||
|
||||
async function fetchComfyNodesForPack(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packId: string,
|
||||
version: string,
|
||||
timeoutMs: number
|
||||
): Promise<RegistryComfyNode[]> {
|
||||
const allNodes: RegistryComfyNode[] = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
while (page <= totalPages) {
|
||||
const result = await fetchComfyNodesPageWithRetry(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
version,
|
||||
page,
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
if (!result) break
|
||||
|
||||
allNodes.push(...result.nodes)
|
||||
totalPages = result.totalPages
|
||||
page++
|
||||
}
|
||||
|
||||
return allNodes
|
||||
}
|
||||
|
||||
async function fetchComfyNodesPageWithRetry(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packId: string,
|
||||
version: string,
|
||||
page: number,
|
||||
timeoutMs: number
|
||||
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
|
||||
const firstAttempt = await fetchComfyNodesPage(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
version,
|
||||
page,
|
||||
timeoutMs
|
||||
)
|
||||
if (firstAttempt) return firstAttempt
|
||||
|
||||
// Retry once on failure
|
||||
return fetchComfyNodesPage(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
packId,
|
||||
version,
|
||||
page,
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchComfyNodesPage(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packId: string,
|
||||
version: string,
|
||||
page: number,
|
||||
timeoutMs: number
|
||||
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!res.ok) return null
|
||||
|
||||
const rawBody: unknown = await res.json()
|
||||
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
|
||||
if (!parsed.success) return null
|
||||
|
||||
return {
|
||||
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
|
||||
totalPages: parsed.data.totalNumberOfPages ?? 1
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBatchWithRetry(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
|
||||
@@ -8,16 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
|
||||
|
||||
import type { RegistryPackWithNodes } from './cloudNodes.registry'
|
||||
|
||||
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
|
||||
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
|
||||
)
|
||||
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
|
||||
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes.registry', () => ({
|
||||
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
|
||||
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
|
||||
fetchRegistryPacks: fetchRegistryPacksMock
|
||||
}))
|
||||
|
||||
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
|
||||
@@ -94,8 +90,8 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesFetcherForTests()
|
||||
fetchRegistryPacksWithNodesMock.mockReset()
|
||||
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
|
||||
fetchRegistryPacksMock.mockReset()
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
sanitizeCallSpy.mockReset()
|
||||
delete process.env.WEBSITE_CLOUD_API_KEY
|
||||
})
|
||||
@@ -106,21 +102,14 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
})
|
||||
|
||||
it('returns fresh when API succeeds', async () => {
|
||||
fetchRegistryPacksWithNodesMock.mockResolvedValue(
|
||||
new Map<string, RegistryPackWithNodes | null>([
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'comfyui-impact-pack',
|
||||
{
|
||||
pack: {
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
|
||||
latest_version: { version: '1.0.0' }
|
||||
},
|
||||
nodes: [
|
||||
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
|
||||
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
|
||||
]
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
}
|
||||
]
|
||||
])
|
||||
@@ -140,10 +129,6 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
||||
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
)
|
||||
// Nodes should come from registry, not object_info
|
||||
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
|
||||
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
|
||||
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
|
||||
})
|
||||
|
||||
it('drops invalid nodes individually and keeps valid nodes', async () => {
|
||||
@@ -312,7 +297,7 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
})
|
||||
|
||||
it('returns fresh even when registry enrichment fails', async () => {
|
||||
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
@@ -320,8 +305,5 @@ describe('fetchCloudNodesForBuild', () => {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
// Falls back to object_info nodes when registry fails
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,15 +6,12 @@ import {
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
import type {
|
||||
RegistryComfyNode,
|
||||
RegistryPackWithNodes
|
||||
} from './cloudNodes.registry'
|
||||
import type { RegistryPack } from './cloudNodes.registry'
|
||||
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
|
||||
|
||||
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
|
||||
import { isNodesSnapshot } from '../data/cloudNodes'
|
||||
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
|
||||
import { fetchRegistryPacks } from './cloudNodes.registry'
|
||||
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
@@ -238,28 +235,26 @@ async function parseCloudNodes(
|
||||
const sanitizedDefs = sanitizeUserContent(
|
||||
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
|
||||
)
|
||||
|
||||
// Use object_info to determine which packs are cloud-supported
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
const packIds = grouped.map((pack) => pack.id)
|
||||
|
||||
// Fetch full pack metadata and node list from registry
|
||||
let registryMap = new Map<string, RegistryPackWithNodes | null>()
|
||||
let registryMap = new Map<string, RegistryPack | null>()
|
||||
try {
|
||||
registryMap = await fetchRegistryPacksWithNodes(packIds, {
|
||||
fetchImpl: options.fetchImpl
|
||||
})
|
||||
registryMap = await fetchRegistryPacks(
|
||||
grouped.map((pack) => pack.id),
|
||||
{ fetchImpl: options.fetchImpl }
|
||||
)
|
||||
} catch {
|
||||
registryMap = new Map()
|
||||
}
|
||||
|
||||
const packs = grouped
|
||||
.map((pack) => {
|
||||
const registryData = registryMap.get(pack.id)
|
||||
// Use registry nodes if available, otherwise fall back to object_info nodes
|
||||
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
|
||||
})
|
||||
.filter((pack) => pack.nodes.length > 0)
|
||||
const packs = grouped.map((pack) =>
|
||||
toDomainPack(
|
||||
pack.id,
|
||||
pack.displayName,
|
||||
pack.nodes,
|
||||
registryMap.get(pack.id)
|
||||
)
|
||||
)
|
||||
|
||||
return { kind: 'ok', packs, droppedNodes }
|
||||
}
|
||||
@@ -279,7 +274,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackDisplayName: string,
|
||||
objectInfoNodes: Array<{
|
||||
nodes: Array<{
|
||||
className: string
|
||||
def: {
|
||||
display_name: string
|
||||
@@ -289,18 +284,8 @@ function toDomainPack(
|
||||
experimental?: boolean
|
||||
}
|
||||
}>,
|
||||
registryData: RegistryPackWithNodes | null | undefined
|
||||
registryPack: RegistryPack | null | undefined
|
||||
): Pack {
|
||||
const registryPack = registryData?.pack
|
||||
|
||||
// Prefer registry nodes if available, fall back to object_info nodes
|
||||
const nodes =
|
||||
registryData?.nodes && registryData.nodes.length > 0
|
||||
? registryData.nodes
|
||||
.map((node) => toDomainNodeFromRegistry(node))
|
||||
.filter((n): n is PackNode => n !== null)
|
||||
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
|
||||
|
||||
return {
|
||||
id: packId,
|
||||
registryId: registryPack?.id,
|
||||
@@ -323,20 +308,9 @@ function toDomainPack(
|
||||
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
|
||||
supportedOs: registryPack?.supported_os,
|
||||
supportedAccelerators: registryPack?.supported_accelerators,
|
||||
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
|
||||
if (!node.comfy_node_name) return null
|
||||
|
||||
return {
|
||||
name: node.comfy_node_name,
|
||||
displayName: node.comfy_node_name,
|
||||
category: node.category || '',
|
||||
description: node.description || undefined,
|
||||
deprecated: node.deprecated,
|
||||
experimental: node.experimental
|
||||
nodes: nodes
|
||||
.map((node) => toDomainNode(node.className, node.def))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["first-host", 11]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [900, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["second-host", 22]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["9999", "missing_widget"]]
|
||||
},
|
||||
"widgets_values": ["quarantined-host-value"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
@@ -86,12 +84,11 @@ export class ComfyNodeSearchBoxV2 {
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async openByDoubleClickCanvas(position?: Position) {
|
||||
const { x, y } = position ?? { x: 200, y: 200 }
|
||||
async openByDoubleClickCanvas(): Promise<void> {
|
||||
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
|
||||
// does not intercept; coords target a viewport spot that is on the canvas
|
||||
// and clear of both the side toolbar and any default-graph nodes.
|
||||
await this.comfyPage.page.mouse.dblclick(x, y, { delay: 5 })
|
||||
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
}
|
||||
|
||||
async ensureV2Search(): Promise<void> {
|
||||
@@ -112,14 +109,4 @@ export class ComfyNodeSearchBoxV2 {
|
||||
'search box'
|
||||
)
|
||||
}
|
||||
|
||||
async addNode(query: string, options: { position?: Position } = {}) {
|
||||
const position = options.position ?? { x: 200, y: 200 }
|
||||
await this.openByDoubleClickCanvas(position)
|
||||
await this.input.fill(query)
|
||||
await expect(this.results.first()).toContainText(query)
|
||||
await this.comfyPage.page.keyboard.press('Enter')
|
||||
await expect(this.dialog).toBeHidden()
|
||||
await this.comfyPage.page.mouse.click(position.x, position.y)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,8 @@ export class SubgraphEditor {
|
||||
)
|
||||
}
|
||||
|
||||
async ensureOpen(subgraphNode: Locator) {
|
||||
async open(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
if (await this.root.isVisible()) return
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
@@ -70,7 +69,7 @@ export class SubgraphEditor {
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.ensureOpen(subgraphNode)
|
||||
await this.open(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
|
||||
@@ -2,24 +2,10 @@ import type { Locator } from '@playwright/test'
|
||||
|
||||
export class WidgetSelectDropdownFixture {
|
||||
public readonly selection: Locator
|
||||
public readonly trigger: Locator
|
||||
|
||||
constructor(public readonly root: Locator) {
|
||||
this.trigger = root.locator('button:has(> span)').first()
|
||||
this.selection = root.locator('button span span')
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.trigger.click()
|
||||
}
|
||||
|
||||
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
|
||||
await this.open()
|
||||
const searchInput = popover.getByRole('textbox')
|
||||
await searchInput.fill(query)
|
||||
await searchInput.press('Enter')
|
||||
}
|
||||
|
||||
async selectedItem(): Promise<string> {
|
||||
return await this.selection.innerText()
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
*
|
||||
* Widgets are located by `nodeId:widgetName` suffix against the
|
||||
* `data-widget-key` attribute, which carries the canonical
|
||||
* `graphId:nodeId:widgetName` WidgetEntityId.
|
||||
* Widgets are located by their key (format: "nodeId:widgetName") via the
|
||||
* `data-widget-key` attribute on each widget item.
|
||||
*/
|
||||
export class AppModeWidgetHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -21,14 +19,9 @@ export class AppModeWidgetHelper {
|
||||
return this.comfyPage.appMode.linearWidgets
|
||||
}
|
||||
|
||||
/** Get a widget item container by its `nodeId:widgetName` suffix. */
|
||||
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
|
||||
getWidgetItem(key: string): Locator {
|
||||
return this.container.locator(`[data-widget-key$=":${key}"]`)
|
||||
}
|
||||
|
||||
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
|
||||
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
|
||||
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
|
||||
return this.container.locator(`[data-widget-key="${key}"]`)
|
||||
}
|
||||
|
||||
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
|
||||
|
||||
@@ -11,11 +11,8 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
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
|
||||
@@ -244,17 +241,6 @@ export class SubgraphHelper {
|
||||
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
|
||||
}
|
||||
|
||||
async getInputBounds(): Promise<Position & Size> {
|
||||
return await this.comfyPage.page.evaluate(() => {
|
||||
const graph = app!.canvas.graph as Subgraph
|
||||
const inputNode = graph.inputNode
|
||||
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
|
||||
const width = inputNode.size[0] * app!.canvas.ds.scale
|
||||
const height = inputNode.size[1] * app!.canvas.ds.scale
|
||||
return { x, y, width, height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph input.
|
||||
* This creates a new input slot on the subgraph if targetInputName is not provided.
|
||||
@@ -425,9 +411,39 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
> {
|
||||
return getAllHostPromotedWidgets(this.comfyPage)
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
|
||||
@@ -62,39 +62,12 @@ export class WorkflowHelper {
|
||||
|
||||
async waitForDraftPersisted() {
|
||||
await this.comfyPage.page.waitForFunction(() =>
|
||||
Object.keys(localStorage).some((key) =>
|
||||
key.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Waits for V2 draft index recency, not payload content freshness. */
|
||||
async waitForDraftIndexUpdatedSince(updatedSince: number) {
|
||||
await this.comfyPage.page.waitForFunction((indexUpdatedSince) => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
|
||||
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (!json) continue
|
||||
|
||||
try {
|
||||
const index = JSON.parse(json)
|
||||
if (
|
||||
typeof index.updatedAt === 'number' &&
|
||||
index.updatedAt >= indexUpdatedSince
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed storage while waiting for persistence.
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, updatedSince)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current page and waits for the app to initialize.
|
||||
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '@comfyorg/ingest-types/zod'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
JobStatus,
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
@@ -41,7 +40,7 @@ interface JobsListRoute {
|
||||
responseLimit?: number
|
||||
}
|
||||
|
||||
export interface JobsScenario {
|
||||
interface JobsScenario {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
}
|
||||
@@ -183,24 +182,6 @@ export class JobsRouteMocker {
|
||||
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
|
||||
}
|
||||
|
||||
async mockDeleteHistory(): Promise<HistoryManageRequest[]> {
|
||||
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
|
||||
}
|
||||
|
||||
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
|
||||
await this.page.route(
|
||||
(url) => url.pathname.endsWith(`/api/jobs/${encodeURIComponent(jobId)}`),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'GET') {
|
||||
await requestRoute.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
await requestRoute.fulfill({ json: detail })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async mockPostManageRoute<TRequest>(
|
||||
type: 'queue' | 'history',
|
||||
requestSchema: z.ZodType<TRequest>,
|
||||
|
||||
@@ -76,15 +76,7 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog',
|
||||
openSharedWorkflow: 'open-shared-workflow-dialog',
|
||||
openSharedWorkflowTitle: 'open-shared-workflow-title',
|
||||
openSharedWorkflowClose: 'open-shared-workflow-close',
|
||||
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
|
||||
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
|
||||
openSharedWorkflowOpenWithoutImporting:
|
||||
'open-shared-workflow-open-without-importing',
|
||||
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ImportPublishedAssetsRequest,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
export const sharedWorkflowImportScenario = {
|
||||
shareId: 'shared-missing-media-e2e',
|
||||
workflowId: 'shared-missing-media-workflow',
|
||||
publishedAssetId: 'published-input-asset-1',
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
export interface SharedWorkflowImportMocks {
|
||||
resetAndStartRecording: () => void
|
||||
getImportBody: () => ImportPublishedAssetsRequest | undefined
|
||||
getRequestEvents: () => SharedWorkflowRequestEvent[]
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
|
||||
}
|
||||
|
||||
const defaultInputFileName = '00000000000000000000000Aexample.png'
|
||||
|
||||
const sharedWorkflowAsset: AssetInfo = {
|
||||
id: sharedWorkflowImportScenario.publishedAssetId,
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const sharedWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: sharedWorkflowImportScenario.shareId,
|
||||
workflow_id: sharedWorkflowImportScenario.workflowId,
|
||||
name: 'Shared Missing Media Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
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: [sharedWorkflowImportScenario.inputFileName, 'image']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
assets: [sharedWorkflowAsset]
|
||||
}
|
||||
|
||||
export const sharedWorkflowImportFixture = base.extend<{
|
||||
sharedWorkflowImportMocks: SharedWorkflowImportMocks
|
||||
}>({
|
||||
sharedWorkflowImportMocks: async ({ page }, use) => {
|
||||
const mocks = await mockSharedWorkflowImportFlow(page)
|
||||
await use(mocks)
|
||||
}
|
||||
})
|
||||
|
||||
async function mockSharedWorkflowImportFlow(
|
||||
page: Page
|
||||
): Promise<SharedWorkflowImportMocks> {
|
||||
let isRecording = false
|
||||
let importEndpointCalled = false
|
||||
let importBody: ImportPublishedAssetsRequest | undefined
|
||||
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
|
||||
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
const requestEvents: SharedWorkflowRequestEvent[] = []
|
||||
|
||||
function resetPublicInclusiveInputAssetResponseWaiter() {
|
||||
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
|
||||
if (isRecording) requestEvents.push(event)
|
||||
}
|
||||
|
||||
await page.route(
|
||||
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sharedWorkflowResponse)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await page.route('**/api/assets/import', async (route) => {
|
||||
recordRequestEvent('import')
|
||||
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
|
||||
importEndpointCalled = true
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Excludes `/api/assets/import` so the specific route above
|
||||
// remains isolated from the general asset listing mock.
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = getTagParam(url, 'include_tags')
|
||||
const isInputAssetRequest = includeTags.includes('input')
|
||||
const includesPublicAssets =
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
const isPublicInclusiveInputAssetRequest =
|
||||
isInputAssetRequest && includesPublicAssets
|
||||
const isAfterImportPublicInclusiveInputAssetRequest =
|
||||
isPublicInclusiveInputAssetRequest && importEndpointCalled
|
||||
|
||||
if (isPublicInclusiveInputAssetRequest) {
|
||||
recordRequestEvent(
|
||||
importEndpointCalled
|
||||
? 'input-assets-including-public-after-import'
|
||||
: 'input-assets-including-public-before-import'
|
||||
)
|
||||
}
|
||||
|
||||
const allAssets = [
|
||||
defaultInputAsset,
|
||||
...(importEndpointCalled ? [importedInputAsset] : [])
|
||||
]
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
|
||||
if (isAfterImportPublicInclusiveInputAssetRequest) {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
resetAndStartRecording: () => {
|
||||
isRecording = true
|
||||
importEndpointCalled = false
|
||||
importBody = undefined
|
||||
requestEvents.length = 0
|
||||
resetPublicInclusiveInputAssetResponseWaiter()
|
||||
},
|
||||
getImportBody: () => importBody,
|
||||
getRequestEvents: () => [...requestEvents],
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
|
||||
publicInclusiveInputAssetResponseAfterImport
|
||||
}
|
||||
}
|
||||
|
||||
function getTagParam(url: URL, key: string): string[] {
|
||||
return (
|
||||
url.searchParams
|
||||
.get(key)
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
@@ -511,7 +511,8 @@ export class NodeReference {
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
await this.comfyPage.contextMenu.clickMenuItem(optionText)
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
|
||||
@@ -1,77 +1,48 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
|
||||
function previewExposureToEntry(
|
||||
exposure: PreviewExposure
|
||||
): PromotedWidgetEntry {
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetSource(
|
||||
value: unknown
|
||||
): value is PromotedWidgetSource {
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
}
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
return widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
@@ -107,29 +78,12 @@ export async function getPromotedWidgetCountByName(
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.filter(([, name]) => name === widgetName).length
|
||||
}
|
||||
|
||||
export async function getAllHostPromotedWidgets(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
|
||||
const hostNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => String(node.id))
|
||||
})
|
||||
|
||||
const entries = await Promise.all(
|
||||
hostNodeIds.map(async (hostNodeId) => ({
|
||||
hostNodeId,
|
||||
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
|
||||
}))
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
|
||||
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
|
||||
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
const { centerPanel } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(centerPanel, 'Enter app mode').toBeVisible()
|
||||
@@ -19,12 +25,15 @@ test.describe('App mode usage', () => {
|
||||
//prep a load image
|
||||
await test.step('Add a load image node', async () => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(loadImage).toBeVisible()
|
||||
})
|
||||
|
||||
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
|
||||
const imageInput = new WidgetSelectDropdownFixture(
|
||||
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
|
||||
)
|
||||
|
||||
await test.step('Enter app mode with image input', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
|
||||
@@ -98,45 +107,6 @@ test.describe('App mode usage', () => {
|
||||
//verify values are consistent with litegraph
|
||||
})
|
||||
|
||||
test('FormDropdown search Enter selects the top filtered item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const fileComboWidget = await loadImageNode.getWidget(0)
|
||||
const targetImage = String(await fileComboWidget.getValue())
|
||||
const initialImage = 'not-selected.png'
|
||||
await comfyPage.page.evaluate(
|
||||
([nodeId, value]) => {
|
||||
const node = window.app!.graph!.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.[0]
|
||||
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
|
||||
|
||||
widget.value = value
|
||||
},
|
||||
[loadImageNode.id, initialImage] as const
|
||||
)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
[String(loadImageNode.id), 'image']
|
||||
])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
|
||||
`${loadImageNode.id}:image`
|
||||
)
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
|
||||
await expect(imageInput.root).toBeVisible()
|
||||
await imageInput.searchAndSelectTop(popover, targetImage)
|
||||
|
||||
await expect(popover).toBeHidden()
|
||||
await expect(imageInput.selection).toHaveText(targetImage)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
|
||||
@@ -75,28 +75,33 @@ test.describe('App mode builder selection', () => {
|
||||
})
|
||||
|
||||
test('Marks canvas readOnly', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBoxV2.input,
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is initially editable'
|
||||
).toBeVisible()
|
||||
).toHaveCount(1)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBoxV2.input,
|
||||
comfyPage.searchBox.input,
|
||||
'Entering builder makes the canvas readonly'
|
||||
).toBeHidden()
|
||||
).toHaveCount(0)
|
||||
|
||||
await comfyPage.page.keyboard.press('Space')
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBoxV2.input,
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas remains readonly after pressing space'
|
||||
).toBeHidden()
|
||||
).toHaveCount(0)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
@@ -107,10 +112,10 @@ test.describe('App mode builder selection', () => {
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBoxV2.input,
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is no longer readonly after exiting'
|
||||
).toBeVisible()
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,10 +131,13 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
|
||||
`${loadImageId}:image`
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
const imageRow = widgetList.locator(
|
||||
'div:has(> div > span:text-is("image"))'
|
||||
)
|
||||
await imageInput.open()
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -361,15 +361,3 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
@@ -44,45 +43,4 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
|
||||
await expect(comfyPage.canvas).toBeHidden()
|
||||
})
|
||||
|
||||
test('Spinner persists until workflow loaded', async ({
|
||||
page,
|
||||
request
|
||||
}, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
const { parallelIndex } = testInfo
|
||||
const username = `playwright-test-${parallelIndex}`
|
||||
const userId = await comfyPage.setupUser(username)
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
await page.goto(`${comfyPage.url}/api/users`)
|
||||
await page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, comfyPage.id)
|
||||
|
||||
const splash = page.locator('#splash-loader')
|
||||
|
||||
let notifyWorkflowRequested!: () => void
|
||||
const workflowRequested = new Promise<void>(
|
||||
(r) => (notifyWorkflowRequested = r)
|
||||
)
|
||||
let unblockRequest!: () => void
|
||||
const requestUnblocked = new Promise<void>((r) => (unblockRequest = r))
|
||||
|
||||
await page.route('**/templates/default.json', async (route) => {
|
||||
notifyWorkflowRequested()
|
||||
await requestUnblocked
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await comfyPage.goto({ url: `${comfyPage.url}/?template=default` })
|
||||
await workflowRequested
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await expect(splash).toBeVisible()
|
||||
unblockRequest()
|
||||
await expect(splash).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
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)
|
||||
|
||||
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.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())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB |
@@ -1,147 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
sharedWorkflowImportFixture,
|
||||
sharedWorkflowImportScenario
|
||||
} from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const IMPORT_ORDER_TIMEOUT_MS = 5_000
|
||||
|
||||
async function expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await expect(async () => {
|
||||
const events = mocks.getRequestEvents()
|
||||
const importIndex = events.indexOf('import')
|
||||
const afterImportIndex = events.indexOf(
|
||||
'input-assets-including-public-after-import'
|
||||
)
|
||||
|
||||
expect(
|
||||
events,
|
||||
'public-inclusive input assets must not be scanned before import'
|
||||
).not.toContain('input-assets-including-public-before-import')
|
||||
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
|
||||
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
|
||||
importIndex
|
||||
)
|
||||
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
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
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage: ComfyPage,
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
async function openPanelAndExpectNoMissingMedia(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const page = comfyPage.page
|
||||
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
|
||||
|
||||
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
await comfyPage.setup({
|
||||
clearStorage: false,
|
||||
url: `/?share=${sharedWorkflowImportScenario.shareId}`
|
||||
})
|
||||
})
|
||||
|
||||
test('imports shared media before loading workflow so missing media is not surfaced', async ({
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((node) => ({
|
||||
type: node.type,
|
||||
value: node.widgets?.[0]?.value
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
value: sharedWorkflowImportScenario.inputFileName
|
||||
}
|
||||
])
|
||||
await expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
|
||||
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
|
||||
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
|
||||
share_id: sharedWorkflowImportScenario.shareId
|
||||
})
|
||||
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
|
||||
await openPanelAndExpectNoMissingMedia(comfyPage)
|
||||
})
|
||||
})
|
||||
@@ -10,9 +10,6 @@ import type {
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture,
|
||||
routeMockJobTimestamp
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
|
||||
interface ViewFile {
|
||||
body?: Buffer | string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
type ViewFilesByName = Readonly<Record<string, ViewFile>>
|
||||
|
||||
const transparentPng = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
)
|
||||
|
||||
const alphaJob = createRouteMockJob({
|
||||
id: 'alpha',
|
||||
create_time: routeMockJobTimestamp - 1_000,
|
||||
execution_start_time: routeMockJobTimestamp - 1_000,
|
||||
execution_end_time: routeMockJobTimestamp,
|
||||
preview_output: {
|
||||
filename: 'alpha.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
|
||||
const betaJob = createRouteMockJob({
|
||||
id: 'beta',
|
||||
create_time: routeMockJobTimestamp - 2_000,
|
||||
execution_start_time: routeMockJobTimestamp - 2_000,
|
||||
execution_end_time: routeMockJobTimestamp,
|
||||
preview_output: {
|
||||
filename: 'beta.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
|
||||
const multiOutputJob = createRouteMockJob({
|
||||
id: 'multi-output',
|
||||
create_time: routeMockJobTimestamp - 3_000,
|
||||
execution_start_time: routeMockJobTimestamp - 3_000,
|
||||
execution_end_time: routeMockJobTimestamp,
|
||||
preview_output: {
|
||||
filename: 'multi-output-a.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
|
||||
const multiOutputJobDetail: JobDetail = {
|
||||
...multiOutputJob,
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [
|
||||
{
|
||||
filename: 'multi-output-a.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
},
|
||||
{
|
||||
filename: 'multi-output-b.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generatedJobs: RawJobListItem[] = [alphaJob, betaJob]
|
||||
|
||||
const viewFiles = {
|
||||
'alpha.png': {},
|
||||
'beta.png': {},
|
||||
'imported.png': {},
|
||||
'multi-output-a.png': {},
|
||||
'multi-output-b.png': {}
|
||||
}
|
||||
|
||||
async function mockInputFiles(page: Page, files: readonly string[]) {
|
||||
await page.route('**/internal/files/input**', async (route) => {
|
||||
if (route.request().method().toUpperCase() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({ json: [...files] })
|
||||
})
|
||||
}
|
||||
|
||||
async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
|
||||
await page.route('**/api/view**', async (route) => {
|
||||
if (route.request().method().toUpperCase() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename')
|
||||
if (!filename) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
json: { error: 'Missing filename' } satisfies { error: string }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const file = filesByName[filename]
|
||||
if (!file) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
json: {
|
||||
error: `Unknown filename: ${filename}`
|
||||
} satisfies { error: string }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
body: file.body ?? transparentPng,
|
||||
contentType: file.contentType ?? 'image/png'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
test.beforeEach(async ({ jobsRoutes, page }) => {
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
await jobsRoutes.mockJobsHistory(generatedJobs)
|
||||
await mockInputFiles(page, ['imported.png'])
|
||||
await mockViewFiles(page, viewFiles)
|
||||
})
|
||||
|
||||
test('renders generated and imported assets with image previews', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
|
||||
await expect(tab.getAssetCardByName('beta')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('img', { name: 'alpha.png' })
|
||||
).toHaveJSProperty('naturalWidth', 1)
|
||||
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.getAssetCardByName('imported')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('img', { name: 'imported.png' })
|
||||
).toHaveJSProperty('naturalWidth', 1)
|
||||
})
|
||||
|
||||
test('opens previews for generated and imported images', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.page.getByRole('img', { name: 'alpha.png' }).dblclick()
|
||||
await expect(comfyPage.mediaLightbox.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.mediaLightbox.root.getByRole('img', {
|
||||
name: 'alpha.png'
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.mediaLightbox.closeButton.click()
|
||||
await expect(comfyPage.mediaLightbox.root).toBeHidden()
|
||||
|
||||
await tab.switchToImported()
|
||||
|
||||
await comfyPage.page.getByRole('img', { name: 'imported.png' }).dblclick()
|
||||
await expect(comfyPage.mediaLightbox.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.mediaLightbox.root.getByRole('img', {
|
||||
name: 'imported.png'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows footer actions for single and multiple generated selections', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.getAssetCardByName('beta').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('loads full generated job outputs from job detail', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await jobsRoutes.mockJobsHistory([multiOutputJob])
|
||||
await jobsRoutes.mockJobDetail('multi-output', multiOutputJobDetail)
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
|
||||
await tab
|
||||
.getAssetCardByName('multi-output-a')
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect(tab.getAssetCardByName('multi-output-b')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('img', { name: 'multi-output-b.png' })
|
||||
).toHaveJSProperty('naturalWidth', 1)
|
||||
})
|
||||
|
||||
test('deletes a generated output asset through explicit history refresh', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await comfyPage.setup()
|
||||
await tab.open()
|
||||
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
|
||||
|
||||
const deleteRequests = await jobsRoutes.mockDeleteHistory()
|
||||
await jobsRoutes.mockJobsHistory([betaJob])
|
||||
|
||||
await tab.getAssetCardByName('alpha').click({ button: 'right' })
|
||||
await tab.contextMenuItem('Delete').click()
|
||||
await comfyPage.confirmDialog.delete.click()
|
||||
|
||||
await expect.poll(() => deleteRequests).toHaveLength(1)
|
||||
expect(deleteRequests[0]).toEqual({ delete: ['alpha'] })
|
||||
await expect(tab.getAssetCardByName('alpha')).toHaveCount(0)
|
||||
await expect(comfyPage.toast.toastSuccesses).toContainText(
|
||||
'Asset deleted successfully'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
jobsRouteFixture,
|
||||
routeMockJobTimestamp
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsScenario } from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
|
||||
const historyJobs: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'history-completed',
|
||||
@@ -73,24 +75,21 @@ const activeJobs: RawJobListItem[] = [
|
||||
]
|
||||
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture).extend<{
|
||||
initialJobsScenario: JobsScenario
|
||||
mockInitialJobsScenario: void
|
||||
}>({
|
||||
initialJobsScenario: [
|
||||
{ history: historyJobs, queue: activeJobs },
|
||||
{ option: true }
|
||||
],
|
||||
mockInitialJobsScenario: [
|
||||
async ({ jobsRoutes, initialJobsScenario }, use) => {
|
||||
await jobsRoutes.mockJobsScenario(initialJobsScenario)
|
||||
await use()
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
async function setupJobHistorySidebar(
|
||||
comfyPage: ComfyPage,
|
||||
jobsRoutes: JobsRouteMocker,
|
||||
{
|
||||
history = historyJobs,
|
||||
queue = activeJobs
|
||||
}: {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
} = {}
|
||||
) {
|
||||
await jobsRoutes.mockJobsScenario({ history, queue })
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
|
||||
await comfyPage.setup()
|
||||
|
||||
async function openJobHistorySidebar(comfyPage: ComfyPage) {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.sidebar.toolbar)
|
||||
.getByRole('button', { name: 'Job History', exact: true })
|
||||
@@ -123,152 +122,159 @@ async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
|
||||
}
|
||||
|
||||
test.describe('Job history sidebar', { tag: '@ui' }, () => {
|
||||
test.describe('docked overlay action', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': false } })
|
||||
|
||||
test('opens from the queue overlay docked history action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
await comfyPage.queuePanel.moreOptionsButton.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.queue.dockedJobHistoryAction)
|
||||
.click()
|
||||
|
||||
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
|
||||
test('opens from the queue overlay docked history action', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: activeJobs
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
await comfyPage.queuePanel.moreOptionsButton.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.queue.dockedJobHistoryAction)
|
||||
.click()
|
||||
|
||||
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('expanded history tab', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
|
||||
test('shows terminal and active job states', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
test('shows terminal and active job states', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeEnabled()
|
||||
})
|
||||
|
||||
test('filters completed and failed history jobs', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Completed', exact: true })
|
||||
.click()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Failed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
})
|
||||
|
||||
test('searches by job id and output filename', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search...')
|
||||
|
||||
await searchInput.fill('history-failed')
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await searchInput.fill('completed-output')
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
|
||||
await searchInput.clear()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clears pending queue jobs and leaves running/history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
await clearQueueButton(comfyPage).click()
|
||||
|
||||
await expect.poll(() => clearQueueRequests.length).toBe(1)
|
||||
expect(clearQueueRequests).toContainEqual({ clear: true })
|
||||
await expect(row('queue-pending')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
expect(clearHistoryRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('clears history from the sidebar menu and keeps active jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: [],
|
||||
queue: activeJobs
|
||||
})
|
||||
|
||||
await openSidebarClearHistoryDialog(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => clearHistoryRequests.length).toBe(1)
|
||||
expect(clearHistoryRequests).toContainEqual({ clear: true })
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
expect(clearQueueRequests).toHaveLength(0)
|
||||
})
|
||||
await expect(clearQueueButton(comfyPage)).toBeEnabled()
|
||||
})
|
||||
|
||||
test.describe('without pending queue jobs', () => {
|
||||
test.use({
|
||||
initialJobsScenario: { history: historyJobs, queue: runningOnlyJobs },
|
||||
initialSettings: { 'Comfy.Queue.QPOV2': true }
|
||||
test('filters completed and failed history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Completed', exact: true })
|
||||
.click()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Failed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
})
|
||||
|
||||
test('searches by job id and output filename', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search...')
|
||||
|
||||
await searchInput.fill('history-failed')
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await searchInput.fill('completed-output')
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
|
||||
await searchInput.clear()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
})
|
||||
|
||||
test('disables clear queue when there are no pending jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes, {
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
test('disables clear queue', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
})
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
test('clears pending queue jobs and leaves running/history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
await clearQueueButton(comfyPage).click()
|
||||
|
||||
await expect.poll(() => clearQueueRequests.length).toBe(1)
|
||||
expect(clearQueueRequests).toContainEqual({ clear: true })
|
||||
await expect(row('queue-pending')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
expect(clearHistoryRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('clears history from the sidebar menu and keeps active jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: [],
|
||||
queue: activeJobs
|
||||
})
|
||||
|
||||
await openSidebarClearHistoryDialog(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => clearHistoryRequests.length).toBe(1)
|
||||
expect(clearHistoryRequests).toContainEqual({ clear: true })
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
expect(clearQueueRequests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,26 +129,4 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
|
||||
test('Click-to-place from sidebar selects the newly added node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await tab.expandFolder('sampling')
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
const target = {
|
||||
x: canvasBox.width / 2,
|
||||
y: canvasBox.height / 2
|
||||
}
|
||||
|
||||
await tab.getNode('KSampler (Advanced)').click()
|
||||
await comfyPage.canvas.click({ position: target })
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,19 +40,49 @@ test.describe(
|
||||
)
|
||||
const [nodeId1, nodeId2] = nodeIds
|
||||
|
||||
const promotedTextarea = (nodeId: string) =>
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
// Enter first subgraph, set text widget value
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea1.fill('subgraph1_value')
|
||||
await expect(textarea1).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await promotedTextarea(nodeId1).fill('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
// Enter second subgraph, set text widget value
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea2.fill('subgraph2_value')
|
||||
await expect(textarea2).toHaveValue('subgraph2_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await promotedTextarea(nodeId2).fill('subgraph2_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
// Re-enter first subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea1Again).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
// Re-enter second subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea2Again).toHaveValue('subgraph2_value')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,46 +1,38 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
test.describe(
|
||||
'Cleanup Behavior After Promoted Source Removal',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Deleting the promoted source removes the exterior promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test('Deleting the promoted source removes the exterior DOM widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
|
||||
|
||||
@@ -34,43 +34,49 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const OUTER_NODE_ID = '4'
|
||||
const INNER_SUBGRAPH_NODE_ID = '3'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
await comfyExpect(async () => {
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(outerWidgets).toHaveCount(1)
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
const exposedTextWidget = outerNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
|
||||
const textWidgets = widgetValues.filter((w) =>
|
||||
w.name.startsWith('text')
|
||||
)
|
||||
comfyExpect(textWidgets).toHaveLength(2)
|
||||
|
||||
const innerNode = comfyPage.vueNodes.getNodeLocator(
|
||||
INNER_SUBGRAPH_NODE_ID
|
||||
)
|
||||
await comfyExpect(innerNode).toBeVisible()
|
||||
|
||||
const innerTextboxes = innerNode.getByRole('textbox')
|
||||
await comfyExpect(innerTextboxes).toHaveCount(2)
|
||||
const innerValues = await innerTextboxes.evaluateAll<
|
||||
string[],
|
||||
HTMLInputElement
|
||||
>((boxes) => boxes.map((b) => b.value))
|
||||
comfyExpect(innerValues).toContain('11111111111')
|
||||
comfyExpect(innerValues).toContain('22222222222')
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,6 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Subgraph Clipboard Operations', () => {
|
||||
@@ -54,7 +58,8 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Note')
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
@@ -61,12 +61,15 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select the positive CLIPTextEncode node (id 6)
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = String(subgraphNode.id)
|
||||
// CLIPTextEncode is in the recommendedNodes list, so its text widget
|
||||
// should be promoted
|
||||
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
|
||||
})
|
||||
|
||||
@@ -75,6 +78,7 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -82,6 +86,7 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
String(subgraphNode.id),
|
||||
@@ -90,79 +95,88 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Widget Visibility in Vue Mode',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
|
||||
test('Promoted text widget is visible on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
const enterButton = subgraphVueNode.getByTestId(
|
||||
'subgraph-enter-button'
|
||||
)
|
||||
await expect(enterButton).toBeVisible()
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
|
||||
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
|
||||
// FIXME: Vue Nodes mode does not currently propagate host promoted
|
||||
// widget value changes into the interior widget's widget store entry.
|
||||
// Tracking bug: cross-mode promoted widget value sync.
|
||||
test.fail(
|
||||
'Promoted and interior widgets stay in sync across navigation',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await promotedTextarea.fill(testContent)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
test.describe('Promoted Widget Reactivity', () => {
|
||||
test('Promoted and interior widgets stay in sync across navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const interiorTextarea = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
.first()
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveValue(updatedInteriorContent)
|
||||
}
|
||||
)
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||
@@ -181,6 +195,7 @@ test.describe(
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
@@ -189,8 +204,10 @@ test.describe(
|
||||
await promoteEntry.click()
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
// Navigate back to parent
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
|
||||
})
|
||||
|
||||
@@ -199,6 +216,7 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// First promote a canvas-rendered widget (KSampler "steps")
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
@@ -214,6 +232,7 @@ test.describe(
|
||||
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
// Wait for the context menu to close, confirming the action completed.
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
@@ -298,6 +317,8 @@ test.describe(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
'5',
|
||||
@@ -310,6 +331,7 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -317,6 +339,7 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is a recommended node, so filename_prefix should be promoted
|
||||
const nodeId = String(subgraphNode.id)
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
@@ -370,49 +393,56 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Nested Promoted Widget Disabled State',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
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(() => 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
|
||||
.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
|
||||
})
|
||||
await expect(linkedTextarea).toBeVisible()
|
||||
await expect(linkedTextarea).toBeDisabled()
|
||||
.toBe(true)
|
||||
|
||||
const allTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(allTextareas.first()).toBeVisible()
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.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
|
||||
}
|
||||
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
|
||||
)
|
||||
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
@@ -422,13 +452,16 @@ test.describe(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Verify promotions exist
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
|
||||
.toEqual(expect.arrayContaining([expect.anything()]))
|
||||
|
||||
// Delete the subgraph node
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
|
||||
// Node no longer exists, so promoted widgets should be gone
|
||||
await expect.poll(() => subgraphNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -487,13 +520,17 @@ test.describe(
|
||||
.toBeGreaterThan(0)
|
||||
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Remove the text input slot
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Widget count should be reduced
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
@@ -571,198 +608,217 @@ test.describe(
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Promote/Demote by Context Menu',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Properties panel operations',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: false
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
})
|
||||
|
||||
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'linked widgets are first by default'
|
||||
).toHaveText('steps')
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(
|
||||
editor.promotionItems.first(),
|
||||
'Swap widget order'
|
||||
).toContainText('cfg')
|
||||
|
||||
// FIXME: solve actual bug and remove the not
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'Linked widget is first on node'
|
||||
).not.toHaveText('cfg')
|
||||
})
|
||||
|
||||
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test(
|
||||
'Link already promoted widget',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
await expect(steps).toHaveCount(1)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
// FUTURE: Add test for re-ordering previews?
|
||||
|
||||
test(
|
||||
'Can promote multiple previews',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Linked widgets can not be demoted',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.ensureOpen(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
}
|
||||
)
|
||||
await editor.open(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
|
||||
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
@@ -29,133 +31,133 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Promotion DOM',
|
||||
{ tag: ['@subgraph', '@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
await parentTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
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 SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget preserves content through subgraph enter/exit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
|
||||
})
|
||||
|
||||
const backToPromoted = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(backToPromoted).toBeVisible()
|
||||
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
test('Promoted text widget is removed when subgraph node is deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNodeRef.delete()
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
})
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
await expect(visibleWidgets).toHaveCount(2)
|
||||
const parentCount = await visibleWidgets.count()
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(promotedTextareas).toHaveCount(2)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
|
||||
const interiorTextareas = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox')
|
||||
await expect(interiorTextareas).toHaveCount(2)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,84 +14,6 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
|
||||
'subgraphs/subgraph-primitive-fanout-multi-host'
|
||||
const UNRESOLVABLE_PROXY_WORKFLOW =
|
||||
'subgraphs/subgraph-unresolvable-proxy-widget'
|
||||
|
||||
interface HostWidgetSnapshot {
|
||||
name: string
|
||||
sourceNodeId: string | null
|
||||
sourceWidgetName: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface PrimitiveFanoutSnapshot {
|
||||
hostWidgetNames: string[]
|
||||
hostWidgetValues: HostWidgetSnapshot[]
|
||||
interiorWidgetValues: unknown[]
|
||||
primitiveOutputLinks: unknown
|
||||
primitiveOriginLinkCount: number
|
||||
serializedProperties: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
|
||||
const primitiveNode = hostNode.subgraph.getNodeById(4)
|
||||
const primitiveOriginLinkCount = [
|
||||
...hostNode.subgraph._links.values()
|
||||
].filter((link) => link.origin_id === 4).length
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
|
||||
return {
|
||||
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
|
||||
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
|
||||
name: widget.name,
|
||||
sourceNodeId:
|
||||
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
|
||||
? widget.sourceNodeId
|
||||
: null,
|
||||
sourceWidgetName:
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
? widget.sourceWidgetName
|
||||
: null,
|
||||
value: widget.value
|
||||
})),
|
||||
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
|
||||
(node.widgets ?? []).map((widget) => widget.value)
|
||||
),
|
||||
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const node = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
return node?.properties ?? {}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -119,174 +41,23 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test(
|
||||
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(beforeReload).toHaveCount(1)
|
||||
await expect(beforeReload).toBeVisible()
|
||||
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(beforeReload.hostWidgetNames).toContain('value')
|
||||
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgets'
|
||||
)
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgetErrorQuarantine'
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
|
||||
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(afterReload.interiorWidgetValues).toEqual(
|
||||
beforeReload.interiorWidgetValues
|
||||
)
|
||||
expect(
|
||||
afterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe(
|
||||
beforeReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
)
|
||||
expect(afterReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(afterReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgets'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Multiple SubgraphNode hosts keep independent migrated widget values',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
|
||||
)
|
||||
|
||||
const expectHostHasIndependentValues = async (
|
||||
hostId: string,
|
||||
stringValue: string,
|
||||
intValue: string
|
||||
) => {
|
||||
const host = comfyPage.vueNodes.getNodeLocator(hostId)
|
||||
const widgets = host.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
|
||||
stringValue
|
||||
)
|
||||
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
|
||||
intValue
|
||||
)
|
||||
}
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Nested preview exposures render through serialized chain resolution',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
test.setTimeout(45_000)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
|
||||
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'8'
|
||||
)
|
||||
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(nestedHostProperties.previewExposures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: '6',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
|
||||
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
await expect(nestedSubgraphNode).toBeVisible()
|
||||
await expect(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
|
||||
.toContain('$$canvas-image-preview')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy unresolvable proxy entry is omitted and quarantined on save',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
|
||||
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host).toBeVisible()
|
||||
await expect(host.getByText('missing_widget')).toHaveCount(0)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
|
||||
.not.toContain('missing_widget')
|
||||
|
||||
const serializedProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'2'
|
||||
)
|
||||
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'missing_widget'],
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 'quarantined-host-value'
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Promoted widget remains usable after serialize and reload',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const beforeReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(beforeReload).toBeVisible()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const afterReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(afterReload).toBeVisible()
|
||||
}
|
||||
)
|
||||
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(afterReload).toHaveCount(1)
|
||||
await expect(afterReload).toBeVisible()
|
||||
})
|
||||
|
||||
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
|
||||
comfyPage
|
||||
@@ -642,10 +413,39 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -666,6 +466,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
@@ -681,14 +482,19 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
await expect(widgetRows.first()).not.toContainText('6: 3:')
|
||||
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -632,72 +632,3 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'link interactions',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const seedSlot = ksampler.getSlot('seed')
|
||||
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
|
||||
|
||||
await test.step('Make second INT typed connection', async () => {
|
||||
const toPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
await stepsSlot.hover()
|
||||
await stepsSlot.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
|
||||
//cancel link operation
|
||||
await stepsSlot.hover()
|
||||
await comfyPage.page.mouse.up()
|
||||
})
|
||||
|
||||
await ksampler.title.hover()
|
||||
|
||||
const slotParent = stepsSlot.locator('../..')
|
||||
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Connect I/O to node with snap', async () => {
|
||||
const hasSnap = () =>
|
||||
comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
|
||||
expect(await hasSnap()).toBe(false)
|
||||
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
//move hover off the slot
|
||||
await ksampler.title.hover()
|
||||
})
|
||||
|
||||
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -1082,10 +1082,17 @@ test.describe(
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
// Setup workflow with a KSampler node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nodeOps.waitForGraphNodes(0)
|
||||
await comfyPage.searchBoxV2.addNode('KSampler')
|
||||
await comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await comfyPage.nodeOps.waitForGraphNodes(1)
|
||||
|
||||
// Convert the KSampler node to a subgraph
|
||||
|
||||
@@ -111,10 +111,12 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
|
||||
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(
|
||||
firstSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(
|
||||
secondSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@@ -19,19 +19,3 @@ test('Can display a slot mismatched from widget type', async ({
|
||||
await expect(width.locator('path[fill*="INT"]')).toBeVisible()
|
||||
await expect(width.locator('path[fill*="FLOAT"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('MatchType updates output color @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await comfyPage.searchBoxV2.addNode('Switch', {
|
||||
position: { x: 600, y: 200 }
|
||||
})
|
||||
const switchNode = await comfyPage.vueNodes.getFixtureByTitle('switch')
|
||||
|
||||
await loadImage.getSlot('MASK').dragTo(switchNode.getSlot('on_false'))
|
||||
const slotEl = switchNode.getSlot('output').locator('.slot-dot')
|
||||
await expect.poll(() => slotEl.getAttribute('style')).toContain('MASK')
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ const file1 = 'workflow.mp4' as const
|
||||
const file2 = 'workflow.webm' as const
|
||||
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
|
||||
@@ -16,7 +18,9 @@ test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await comfyPage.page.mouse.dblclick(500, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Video')
|
||||
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
|
||||
const loadAudioNode = comfyPage.vueNodes.getNodeByTitle('Load Audio')
|
||||
const audioPreview = new AudioPreview(loadAudioNode)
|
||||
|
||||
@@ -12,7 +14,9 @@ test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Audio')
|
||||
//await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Audio')
|
||||
await expect(loadAudioNode).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -9,23 +9,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
)
|
||||
await expect(loadCheckpointNode).toHaveCount(1)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.addWidget('text', 'extra_widget_a', '', () => {})
|
||||
node.widgets!.push(node.widgets![0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.addWidget('text', 'extra_widget_b', '', () => {})
|
||||
node.widgets![2] = node.widgets![0]
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.addWidget('text', 'extra_widget_c', '', () => {})
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
|
||||
@@ -4,103 +4,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
|
||||
const generateUniqueFilename = () =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const waitForWorkflowTabState = async (comfyPage: ComfyPage, minPaths = 2) => {
|
||||
await comfyPage.page.waitForFunction((expectedMinPaths) => {
|
||||
let hasActivePath = false
|
||||
let hasOpenPaths = false
|
||||
|
||||
for (let i = 0; i < window.sessionStorage.length; i++) {
|
||||
const key = window.sessionStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.ActivePath:')) {
|
||||
hasActivePath = true
|
||||
}
|
||||
if (!key?.startsWith('Comfy.Workflow.OpenPaths:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const raw = window.sessionStorage.getItem(key)
|
||||
if (!raw) continue
|
||||
|
||||
try {
|
||||
const state = JSON.parse(raw) as { paths?: unknown[] }
|
||||
hasOpenPaths =
|
||||
Array.isArray(state.paths) && state.paths.length >= expectedMinPaths
|
||||
if (hasActivePath && hasOpenPaths) return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return hasActivePath && hasOpenPaths
|
||||
}, minPaths)
|
||||
}
|
||||
|
||||
type NodeRef = NonNullable<
|
||||
Awaited<ReturnType<ComfyPage['nodeOps']['getFirstNodeRef']>>
|
||||
>
|
||||
|
||||
const getRequiredFirstNodeRef = async (
|
||||
comfyPage: ComfyPage,
|
||||
message: string
|
||||
): Promise<NodeRef> => {
|
||||
const node = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
expect(node, message).toBeDefined()
|
||||
if (!node) throw new Error(message)
|
||||
return node
|
||||
}
|
||||
|
||||
const makeActivePathStale = async (
|
||||
comfyPage: ComfyPage,
|
||||
staleWorkflowName: string,
|
||||
activeWorkflowName: string
|
||||
) => {
|
||||
// Intentionally desync ActivePath from OpenPaths to exercise stale pointer recovery.
|
||||
await comfyPage.page.evaluate(
|
||||
([staleName, activeName]) => {
|
||||
const findStorageKey = (prefix: string) => {
|
||||
for (let i = 0; i < window.sessionStorage.length; i++) {
|
||||
const key = window.sessionStorage.key(i)
|
||||
if (key?.startsWith(prefix)) return key
|
||||
}
|
||||
throw new Error(`Missing ${prefix} persistence key`)
|
||||
}
|
||||
|
||||
const activePathKey = findStorageKey('Comfy.Workflow.ActivePath:')
|
||||
const openPathsKey = findStorageKey('Comfy.Workflow.OpenPaths:')
|
||||
const activePointer = JSON.parse(
|
||||
window.sessionStorage.getItem(activePathKey)!
|
||||
) as { path: string }
|
||||
const openPointer = JSON.parse(
|
||||
window.sessionStorage.getItem(openPathsKey)!
|
||||
) as { paths: string[]; activeIndex: number }
|
||||
const pathForName = (name: string) => {
|
||||
const path = openPointer.paths.find((candidate) =>
|
||||
candidate.endsWith(`${name}.json`)
|
||||
)
|
||||
if (!path) throw new Error(`Missing stored path for ${name}`)
|
||||
return path
|
||||
}
|
||||
|
||||
const stalePath = pathForName(staleName)
|
||||
const activePath = pathForName(activeName)
|
||||
activePointer.path = stalePath
|
||||
openPointer.paths = [stalePath, activePath]
|
||||
openPointer.activeIndex = 1
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
activePathKey,
|
||||
JSON.stringify(activePointer)
|
||||
)
|
||||
window.sessionStorage.setItem(openPathsKey, JSON.stringify(openPointer))
|
||||
},
|
||||
[staleWorkflowName, activeWorkflowName]
|
||||
)
|
||||
}
|
||||
|
||||
async function getNodeOutputImageCount(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -200,11 +103,9 @@ test.describe('Workflow Persistence', () => {
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
|
||||
|
||||
const firstNode = await getRequiredFirstNodeRef(
|
||||
comfyPage,
|
||||
'First node should be available after loading the default workflow'
|
||||
)
|
||||
const nodeId = String(firstNode.id)
|
||||
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
expect(firstNode).toBeTruthy()
|
||||
const nodeId = String(firstNode!.id)
|
||||
|
||||
// Simulate node outputs as if execution completed
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
@@ -481,59 +382,6 @@ test.describe('Workflow Persistence', () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Restores saved workflow drafts from inactive restored tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const workflowA = generateUniqueFilename()
|
||||
const workflowB = generateUniqueFilename()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowA)
|
||||
|
||||
const firstNode = await getRequiredFirstNodeRef(
|
||||
comfyPage,
|
||||
'First node should be available after loading single_ksampler'
|
||||
)
|
||||
await firstNode.centerOnNode()
|
||||
const draftSaveStartedAt = Date.now()
|
||||
await firstNode.toggleCollapse()
|
||||
expect(await firstNode.isCollapsed()).toBe(true)
|
||||
await comfyPage.workflow.waitForDraftIndexUpdatedSince(draftSaveStartedAt)
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
await waitForWorkflowTabState(comfyPage)
|
||||
await makeActivePathStale(comfyPage, workflowA, workflowB)
|
||||
|
||||
await comfyPage.workflow.reloadAndWaitForApp()
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.topbar.getActiveTabName())
|
||||
.toBe(workflowB)
|
||||
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
|
||||
await comfyPage.menu.topbar.getWorkflowTab(workflowA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
|
||||
const restoredNode = await getRequiredFirstNodeRef(
|
||||
comfyPage,
|
||||
'Restored node should be available after switching back to workflow A'
|
||||
)
|
||||
expect(await restoredNode.isCollapsed()).toBe(true)
|
||||
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Closing an inactive tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ Date: 2025-08-25
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (Nx tooling choice superseded by [ADR-0010](0010-remove-nx-orchestration.md))
|
||||
Proposed
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
@@ -31,8 +31,6 @@ For more information on Monorepos, check out [monorepo.tools](https://monorepo.t
|
||||
For monorepo management, I'd probably go with [Nx](https://nx.dev/), but I could be conviced otherwise.
|
||||
There's a [whole list here](https://monorepo.tools/#tools-review) if you're interested.
|
||||
|
||||
> **Update:** The Nx tooling choice has since been reversed. See [ADR-0010: Remove Nx Orchestration](0010-remove-nx-orchestration.md) for the migration to direct pnpm workspace scripts and native tool CLIs.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
@@ -285,11 +285,11 @@ quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` has been removed. Canonical value-widget exposure is
|
||||
represented by linked `SubgraphInput`s. Canonical preview exposure is
|
||||
represented by host-scoped `properties.previewExposures` /
|
||||
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
|
||||
and must not be reintroduced as runtime authority.
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
|
||||
## Considered options
|
||||
|
||||
@@ -325,5 +325,4 @@ for existing workflow consumers that still assume array order.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` is removed; callers query linked inputs or preview exposures
|
||||
directly.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# 10. Remove Nx Orchestration
|
||||
|
||||
Date: 2026-05-19
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-0002](0002-monorepo-conversion.md) adopted [Nx](https://nx.dev/) as a tooling option for managing the
|
||||
ComfyUI Frontend monorepo on top of pnpm workspaces. Nx was introduced as task
|
||||
orchestration to coordinate builds, tests, lints, and types across the apps and
|
||||
packages workspaces.
|
||||
|
||||
In practice, Nx provided little value beyond what pnpm workspaces and the
|
||||
underlying native tool CLIs (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
|
||||
TypeScript) already offer:
|
||||
|
||||
- pnpm's `--filter` and `--recursive` flags already provide topological,
|
||||
parallel, and selective execution across workspaces.
|
||||
- Each underlying tool already has fast, well-supported caching (Vite, Vitest,
|
||||
ESLint, oxlint, TS incremental builds, etc.).
|
||||
- Nx added an extra configuration surface (`nx.json`, `.nxignore`, per-package
|
||||
`nx` blocks), an extra cache layer, an extra `node_modules/.cache/nx`
|
||||
artifact, and an extra CI dimension to debug.
|
||||
- Contributors and AI agents had to learn the Nx mental model in addition to
|
||||
pnpm and the individual tool CLIs.
|
||||
- The Nx daemon and remote-cache features were not in use, so the runtime
|
||||
benefit was limited to local task graph caching, which is largely redundant
|
||||
with the per-tool caches.
|
||||
|
||||
The cost (configuration, mental overhead, surprise behavior, occasional
|
||||
cache-related failures) exceeded the benefit.
|
||||
|
||||
## Decision
|
||||
|
||||
Remove Nx from the repository and run monorepo tasks using:
|
||||
|
||||
- pnpm workspace scripts (`pnpm -r run <script>`,
|
||||
`pnpm --filter <pkg> run <script>`).
|
||||
- Each tool's native CLI (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
|
||||
`vue-tsc`, etc.) invoked directly from the relevant workspace.
|
||||
|
||||
Concretely, this change:
|
||||
|
||||
- Deletes `nx.json` and `.nxignore`.
|
||||
- Removes `nx` entries from root and per-package `package.json` files (the
|
||||
`nx` block on each `package.json`, the dev dependency, and Nx-specific
|
||||
scripts).
|
||||
- Removes `nx`-related entries from `pnpm-workspace.yaml`'s `allowBuilds`.
|
||||
- Rewrites the affected CI workflows (`.github/workflows/ci-tests-e2e.yaml`,
|
||||
`.github/workflows/release-draft-create.yaml`) to call pnpm/native CLIs
|
||||
directly.
|
||||
- Updates `AGENTS.md`, `TROUBLESHOOTING.md`, and
|
||||
[ADR-0002](0002-monorepo-conversion.md) to reflect the new tooling story.
|
||||
- Cleans up Nx-specific lint/format/ignore rules in `.oxlintrc.json`,
|
||||
`eslint.config.ts`, `vite.config.mts`, and `.gitignore`.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Fewer moving parts: no `nx.json`, no `.nx/` cache, no Nx daemon, no
|
||||
Nx-specific scripts to maintain.
|
||||
- Easier onboarding for contributors and AI agents: pnpm + each tool's CLI is
|
||||
the only required knowledge.
|
||||
- CI logs and failures are easier to read because tasks run directly under the
|
||||
tool that owns them, instead of being wrapped by Nx.
|
||||
- Faster, more predictable cache invalidation behavior — each tool owns its
|
||||
own cache and we no longer hit Nx-cache edge cases.
|
||||
- Smaller dependency tree (~2k fewer lines in `pnpm-lock.yaml`).
|
||||
|
||||
### Negative
|
||||
|
||||
- We lose Nx's unified task graph and project graph commands; coordination
|
||||
across workspaces now relies on pnpm filters and explicit script wiring.
|
||||
- We lose Nx's remote/distributed caching as a future option without
|
||||
re-adopting Nx (or a comparable tool like Turborepo).
|
||||
- Contributors who already knew Nx workflows need to relearn the equivalent
|
||||
pnpm invocations.
|
||||
|
||||
## Notes
|
||||
|
||||
- The migration is purely a tooling change; no application behavior, public
|
||||
API, or build output changes.
|
||||
- If we later need more sophisticated task orchestration (e.g. distributed
|
||||
remote cache, fine-grained affected-graph queries), revisit this decision and
|
||||
evaluate Nx, Turborepo, or Moon at that time, with concrete CI/perf data to
|
||||
justify the additional complexity.
|
||||
@@ -8,18 +8,16 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
|
||||
## ADR Index
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
| ----------------------------------------------------------- | ------------------------------------------- | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
|
||||
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
|
||||
| ADR | Title | Status | Date |
|
||||
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -6,17 +6,16 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Five stores extract entity state out of class instances into centralized,
|
||||
queryable registries. Promoted value-widget topology is no longer a store; ADR
|
||||
0009 represents it as ordinary linked `SubgraphInput` state.
|
||||
Six stores extract entity state out of class instances into centralized, queryable registries:
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -100,39 +99,62 @@ graph LR
|
||||
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
|
||||
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
|
||||
|
||||
## 3. Linked promoted widgets and preview exposures
|
||||
## 3. PromotionStore
|
||||
|
||||
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
|
||||
by linked `SubgraphInput`s, and display-only previews are represented by
|
||||
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
|
||||
Legacy `properties.proxyWidgets` is load-time migration input only.
|
||||
**File:** `src/stores/promotionStore.ts`
|
||||
|
||||
### Runtime shape
|
||||
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
|
||||
|
||||
```diagram
|
||||
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
|
||||
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
|
||||
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
|
||||
### State Shape
|
||||
|
||||
╭────────────────╮ ╭──────────────────────╮
|
||||
│ Subgraph host │────▶│ PreviewExposureStore │
|
||||
╰────────────────╯ ╰──────────────────────╯
|
||||
```
|
||||
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
|
||||
│ │ │
|
||||
graphId subgraphNodeId ordered promotion entries
|
||||
|
||||
graphRefCounts: Map<UUID, Map<string, number>>
|
||||
│ │ │
|
||||
graphId entryKey count of nodes promoting this widget
|
||||
```
|
||||
|
||||
`PromotedWidgetViewManager`
|
||||
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
|
||||
synthetic widget views derived from linked subgraph inputs. It does not sit on
|
||||
top of a promotion registry.
|
||||
### Ref-Counting for O(1) Queries
|
||||
|
||||
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
|
||||
|
||||
```ts
|
||||
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
|
||||
// O(1) lookup: refCounts.get(key) > 0
|
||||
```
|
||||
|
||||
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
|
||||
|
||||
### View Reconciliation Layer
|
||||
|
||||
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
PS["PromotionStore
|
||||
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
|
||||
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
|
||||
(proxy widget)"]
|
||||
PV -->|"resolveDeepest()"| CW["Concrete Widget
|
||||
(leaf node)"]
|
||||
PV -->|"reads value"| WVS["WidgetValueStore"]
|
||||
```
|
||||
|
||||
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ----------------------------- | --------- | ------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
|
||||
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
|
||||
| Ref-counted queries | Yes | Efficient global state queries without scanning |
|
||||
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
|
||||
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
|
||||
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
|
||||
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
|
||||
|
||||
## 4. LayoutStore (CRDT)
|
||||
|
||||
@@ -186,8 +208,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
|
||||
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
|
||||
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
|
||||
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
|
||||
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
|
||||
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
|
||||
|
||||
### What's Missing vs Full ECS
|
||||
@@ -200,7 +222,7 @@ graph TD
|
||||
H2["Plain data components
|
||||
(WidgetState, LayoutMap)"]
|
||||
H3["Query APIs
|
||||
(getWidget, preview exposures)"]
|
||||
(getWidget, isPromotedByAny)"]
|
||||
H4["Graph-scoped lifecycle"]
|
||||
H5["Partial position extraction
|
||||
(LayoutStore)"]
|
||||
@@ -227,12 +249,13 @@ graph TD
|
||||
|
||||
Each store invents its own identity scheme:
|
||||
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
@@ -266,6 +289,7 @@ graph TD
|
||||
- value → WidgetValueStore
|
||||
- label → WidgetValueStore
|
||||
- disabled → WidgetValueStore
|
||||
- promotion status → PromotionStore
|
||||
- DOM pos/vis → DomWidgetStore"]
|
||||
W_rem["Remains on class:
|
||||
- _node back-ref
|
||||
@@ -309,8 +333,7 @@ graph TD
|
||||
|
||||
subgraph Subgraph["Subgraph (node component)"]
|
||||
S_ext["Extracted:
|
||||
- value exposure → linked inputs
|
||||
- preview exposure → PreviewExposureStore"]
|
||||
- promotions → PromotionStore"]
|
||||
S_rem["Remains on class:
|
||||
- name, description
|
||||
- inputs[], outputs[]
|
||||
@@ -337,15 +360,15 @@ graph TD
|
||||
|
||||
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export default defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'.i18nrc.cjs',
|
||||
'.nx/*',
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*',
|
||||
'components.d.ts',
|
||||
@@ -373,8 +374,7 @@ export default defineConfig([
|
||||
files: [
|
||||
'src/base/**/*.{ts,vue}',
|
||||
'src/platform/**/*.{ts,vue}',
|
||||
'src/workbench/**/*.{ts,vue}',
|
||||
'src/world/**/*.{ts,vue}'
|
||||
'src/workbench/**/*.{ts,vue}'
|
||||
],
|
||||
rules: {
|
||||
'import-x/no-restricted-paths': [
|
||||
@@ -402,12 +402,6 @@ export default defineConfig([
|
||||
from: './src/renderer/**',
|
||||
message:
|
||||
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
|
||||
},
|
||||
{
|
||||
target: './src/world/**',
|
||||
from: './src/lib/litegraph/**',
|
||||
message:
|
||||
'src/world/ must remain free of litegraph dependencies. The world layer owns canonical entity identity and must not depend on litegraph types or values.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
41
nx.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||
"plugins": [
|
||||
{
|
||||
"plugin": "@nx/eslint/plugin",
|
||||
"options": {
|
||||
"targetName": "lint"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/storybook/plugin",
|
||||
"options": {
|
||||
"serveStorybookTargetName": "storybook",
|
||||
"buildStorybookTargetName": "build-storybook",
|
||||
"testStorybookTargetName": "test-storybook",
|
||||
"staticStorybookTargetName": "static-storybook"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/vite/plugin",
|
||||
"options": {
|
||||
"buildTargetName": "build",
|
||||
"testTargetName": "test",
|
||||
"serveTargetName": "serve",
|
||||
"devTargetName": "dev",
|
||||
"previewTargetName": "preview",
|
||||
"serveStaticTargetName": "serve-static",
|
||||
"typecheckTargetName": "typecheck",
|
||||
"buildDepsTargetName": "build-deps",
|
||||
"watchDepsTargetName": "watch-deps"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/playwright/plugin",
|
||||
"options": {
|
||||
"targetName": "e2e"
|
||||
}
|
||||
}
|
||||
],
|
||||
"analytics": false
|
||||
}
|
||||
68
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.13",
|
||||
"version": "1.45.10",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -8,22 +8,20 @@
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.config.mts",
|
||||
"build:desktop": "pnpm --filter @comfyorg/desktop-ui run build",
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && vite build --config vite.config.mts",
|
||||
"clean": "pnpm dlx rimraf dist dist-ssr coverage playwright-report blob-report test-results node_modules/.vite apps/desktop-ui/dist apps/website/dist",
|
||||
"clean:all": "pnpm clean && pnpm dlx rimraf node_modules",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
|
||||
"dev:desktop": "nx dev @comfyorg/desktop-ui",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
|
||||
"dev": "nx serve",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
@@ -36,32 +34,32 @@
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "pnpm stylelint && oxlint src browser_tests --type-aware && eslint src --cache",
|
||||
"lint:desktop": "pnpm --filter @comfyorg/desktop-ui run lint",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src browser_tests --type-aware",
|
||||
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "vite preview --config vite.config.mts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:desktop": "pnpm --filter @comfyorg/desktop-ui run storybook",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"storybook": "nx storybook",
|
||||
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec playwright test",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
"typecheck:desktop": "pnpm --filter @comfyorg/desktop-ui run typecheck",
|
||||
"typecheck:website": "pnpm --filter @comfyorg/website run typecheck",
|
||||
"zipdist": "node scripts/zipdist.js"
|
||||
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"typecheck:website": "nx run @comfyorg/website:typecheck",
|
||||
"zipdist": "node scripts/zipdist.js",
|
||||
"clean": "nx reset"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/fbx-exporter-three": "^1.0.1",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
@@ -114,7 +112,7 @@
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"semver": "^7.7.2",
|
||||
"three": "catalog:",
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
@@ -132,6 +130,10 @@
|
||||
"@eslint/js": "catalog:",
|
||||
"@intlify/eslint-plugin-vue-i18n": "catalog:",
|
||||
"@lobehub/i18n-cli": "catalog:",
|
||||
"@nx/eslint": "catalog:",
|
||||
"@nx/playwright": "catalog:",
|
||||
"@nx/storybook": "catalog:",
|
||||
"@nx/vite": "catalog:",
|
||||
"@pinia/testing": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -177,6 +179,7 @@
|
||||
"markdown-table": "catalog:",
|
||||
"mixpanel-browser": "catalog:",
|
||||
"monocart-coverage-reports": "catalog:",
|
||||
"nx": "catalog:",
|
||||
"oxfmt": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:",
|
||||
@@ -207,7 +210,20 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x",
|
||||
"pnpm": ">=11"
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.1"
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@firebase/util",
|
||||
"core-js",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"unrs-resolver",
|
||||
"vue-demi"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,12 @@
|
||||
"devDependencies": {
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:design"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.93.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:types"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,11 @@
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:util"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/comfyRegistryTypes.ts"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:types"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1"
|
||||
}
|
||||
|
||||
@@ -17,5 +17,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:util"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3317
pnpm-lock.yaml
generated
@@ -2,11 +2,6 @@ packages:
|
||||
- apps/**
|
||||
- packages/**
|
||||
|
||||
ignoreWorkspaceRootCheck: true
|
||||
catalogMode: prefer
|
||||
publicHoistPattern:
|
||||
- '@parcel/watcher'
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/check': ^0.9.8
|
||||
@@ -21,6 +16,10 @@ catalog:
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.6.1
|
||||
'@nx/playwright': 22.6.1
|
||||
'@nx/storybook': 22.6.1
|
||||
'@nx/vite': 22.6.1
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -32,7 +31,7 @@ catalog:
|
||||
'@primevue/themes': ^4.2.5
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^2.1.0
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
@@ -55,7 +54,7 @@ catalog:
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.184.1
|
||||
'@types/three': ^0.170.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
@@ -98,6 +97,7 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
nx: 22.6.1
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
@@ -113,7 +113,7 @@ catalog:
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
three: ^0.184.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
@@ -144,19 +144,22 @@ catalog:
|
||||
|
||||
cleanupUnusedCatalogs: true
|
||||
|
||||
allowBuilds:
|
||||
'@firebase/util': false
|
||||
'@sentry/cli': true
|
||||
'@tailwindcss/oxide': true
|
||||
core-js: false
|
||||
esbuild: true
|
||||
oxc-resolver: true
|
||||
protobufjs: false
|
||||
sharp: false
|
||||
unrs-resolver: false
|
||||
vue-demi: false
|
||||
ignoredBuiltDependencies:
|
||||
- '@firebase/util'
|
||||
- protobufjs
|
||||
- unrs-resolver
|
||||
- vue-demi
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@playwright/browser-chromium'
|
||||
- '@playwright/browser-firefox'
|
||||
- '@playwright/browser-webkit'
|
||||
- '@sentry/cli'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- nx
|
||||
- oxc-resolver
|
||||
|
||||
overrides:
|
||||
vite: 'catalog:'
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 274 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 281 B After Width: | Height: | Size: 698 B |
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 700 B |
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 302 B After Width: | Height: | Size: 705 B |
|
Before Width: | Height: | Size: 285 B After Width: | Height: | Size: 708 B |
|
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 705 B |
@@ -8,9 +8,7 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -30,6 +28,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
@@ -48,8 +47,26 @@ const hoveringSelectable = ref(false)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
subLabel: t('linearMode.builder.unknownWidget')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
canRename: true
|
||||
}
|
||||
})
|
||||
)
|
||||
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
appModeStore.selectedOutputs.map((nodeId) => [
|
||||
nodeId,
|
||||
@@ -57,6 +74,16 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
function inlineRenameInput(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
newLabel: string
|
||||
) {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) return
|
||||
renameWidget(widget, node, newLabel)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -75,26 +102,22 @@ function getHovered(
|
||||
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
|
||||
}
|
||||
|
||||
function getNodeBounding(nodeId: NodeId) {
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
if (entry.status !== 'resolved') return undefined
|
||||
const { node, widget } = entry
|
||||
if (!widgetName)
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
const marginX = margin ?? BaseWidget.margin
|
||||
@@ -110,11 +133,6 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleDown(e: MouseEvent) {
|
||||
const [node] = getHovered(e) ?? []
|
||||
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||
@@ -139,11 +157,14 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -152,7 +173,7 @@ function nodeToDisplayTuple(
|
||||
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
|
||||
return [
|
||||
n.id,
|
||||
getNodeBounding(n.id),
|
||||
getBounding(n.id),
|
||||
appModeStore.selectedOutputs.some((id) => n.id === id)
|
||||
]
|
||||
}
|
||||
@@ -170,13 +191,10 @@ const renderedOutputs = computed(() => {
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
)
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
||||
`${nodeId}: ${widgetName}`,
|
||||
getBounding(nodeId, widgetName)
|
||||
])
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
@@ -220,28 +238,30 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.widget.label ?? entry.displayName"
|
||||
:sub-title="entry.node.title"
|
||||
can-rename
|
||||
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
|
||||
@rename="renameWidget(entry.widget, entry.node, $event)"
|
||||
/>
|
||||
<IoItem
|
||||
v-else
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
/>
|
||||
</template>
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
canRename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:can-rename="canRename"
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
@rename="inlineRenameInput(nodeId, widgetName, $event)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
@@ -22,12 +23,15 @@ import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
persistedHeight: number | undefined
|
||||
nodeData: ReturnType<typeof nodeToNodeData> & {
|
||||
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
|
||||
@@ -45,25 +49,31 @@ const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
appModeStore.updateInputConfig(widget, config)
|
||||
const { onPointerDown } = useAppModeWidgetResizing(
|
||||
(nodeId, widgetName, config) =>
|
||||
appModeStore.updateInputConfig(nodeId, widgetName, config)
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
LGraphNode,
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { entityId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
@@ -72,7 +82,15 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
return vueWidget.entityId === entityId
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -81,7 +99,9 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: entityId,
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeId,
|
||||
widgetName,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
@@ -148,7 +168,14 @@ defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
|
||||
v-for="{
|
||||
key,
|
||||
nodeId,
|
||||
widgetName,
|
||||
persistedHeight,
|
||||
nodeData,
|
||||
action
|
||||
} in mappedSelections"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
@@ -196,7 +223,8 @@ defineExpose({ handleDragDrop })
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () => appModeStore.removeSelectedInput(action.widget)
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
}
|
||||
]"
|
||||
>
|
||||
@@ -225,7 +253,7 @@ defineExpose({ handleDragDrop })
|
||||
)
|
||||
"
|
||||
:inert="builderMode || undefined"
|
||||
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
|
||||
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
|
||||
>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
|
||||
|
||||
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
|
||||
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
|
||||
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
|
||||
|
||||
function setHeight(el: HTMLElement, height: number) {
|
||||
Object.defineProperty(el, 'offsetHeight', {
|
||||
value: height,
|
||||
@@ -32,13 +28,15 @@ function wrapWithTextarea(initialHeight = 100): {
|
||||
describe('useAppModeWidgetResizing', () => {
|
||||
function setup() {
|
||||
const onResize =
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
const { onPointerDown } = useAppModeWidgetResizing(onResize)
|
||||
|
||||
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
|
||||
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(widget, e as PointerEvent),
|
||||
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
}
|
||||
@@ -49,19 +47,19 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists height when textarea is resized via drag', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
})
|
||||
|
||||
it('does not persist when no height change occurs (e.g. a click)', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -72,7 +70,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists once per drag gesture; stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -88,7 +86,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const button = document.createElement('button')
|
||||
wrapper.appendChild(button)
|
||||
document.body.appendChild(wrapper)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -106,21 +104,21 @@ describe('useAppModeWidgetResizing', () => {
|
||||
wrapper.appendChild(indicator)
|
||||
document.body.appendChild(wrapper)
|
||||
setHeight(indicator, 100)
|
||||
bind(wrapper, WIDGET_IMAGE)
|
||||
bind(wrapper, 1 as NodeId, 'image')
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(indicator, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
|
||||
})
|
||||
|
||||
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const first = wrapWithTextarea()
|
||||
const second = wrapWithTextarea()
|
||||
bind(first.wrapper, WIDGET_PROMPT)
|
||||
bind(second.wrapper, WIDGET_OTHER)
|
||||
bind(first.wrapper, 1 as NodeId, 'prompt')
|
||||
bind(second.wrapper, 2 as NodeId, 'other')
|
||||
|
||||
first.textarea.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true })
|
||||
@@ -134,25 +132,25 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
|
||||
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
|
||||
})
|
||||
|
||||
it('treats pointercancel as the end of a gesture and persists the new height', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
})
|
||||
|
||||
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -161,12 +159,14 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
})
|
||||
|
||||
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
|
||||
const onResize =
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
const scope = effectScope()
|
||||
const { onPointerDown } = scope.run(() =>
|
||||
useAppModeWidgetResizing(onResize)
|
||||
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
|
||||
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
outerIndicator.appendChild(wrapper)
|
||||
document.body.appendChild(outerIndicator)
|
||||
setHeight(outerIndicator, 100)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(outerIndicator, 250)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
|
||||
|
||||
export function useAppModeWidgetResizing(
|
||||
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
|
||||
onResize: (
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
config: InputWidgetConfig
|
||||
) => void
|
||||
) {
|
||||
let pendingHandler: (() => void) | null = null
|
||||
|
||||
@@ -19,7 +23,11 @@ export function useAppModeWidgetResizing(
|
||||
|
||||
onScopeDispose(clearPendingHandler)
|
||||
|
||||
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
|
||||
function onPointerDown(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
event: PointerEvent
|
||||
) {
|
||||
const wrapper = event.currentTarget
|
||||
const target = event.target
|
||||
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
|
||||
@@ -36,7 +44,7 @@ export function useAppModeWidgetResizing(
|
||||
pendingHandler = null
|
||||
const height = resizable.offsetHeight
|
||||
if (height === startHeight) return
|
||||
onResize(widget, { height })
|
||||
onResize(nodeId, widgetName, { height })
|
||||
}
|
||||
pendingHandler = handler
|
||||
window.addEventListener('pointerup', handler)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
nodes: [] as LGraphNode[],
|
||||
events: new EventTarget(),
|
||||
getNodeById: vi.fn() as (id: number) => LGraphNode | null
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function setRootGraphNodes(nodes: LGraphNode[]) {
|
||||
vi.mocked(app.rootGraph).nodes = nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id) => nodes.find((n) => String(n.id) === String(id)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function dispatchRootGraphEvent(type: string) {
|
||||
;(app.rootGraph!.events as unknown as EventTarget).dispatchEvent(
|
||||
new Event(type)
|
||||
)
|
||||
}
|
||||
|
||||
describe('useResolvedSelectedInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
setRootGraphNodes([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('re-resolves selections after a convert-to-subgraph event removes nodes from the root graph', () => {
|
||||
const node = makeNode(1, ['seed'])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
expect(resolved.value[0]?.status).toBe('resolved')
|
||||
|
||||
setRootGraphNodes([])
|
||||
dispatchRootGraphEvent('convert-to-subgraph')
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('re-resolves selections after a subgraph-created event removes nodes from the root graph', () => {
|
||||
const node = makeNode(1, ['seed'])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
expect(resolved.value[0]?.status).toBe('resolved')
|
||||
|
||||
setRootGraphNodes([])
|
||||
dispatchRootGraphEvent('subgraph-created')
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
entityId: WidgetEntityId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
entityId: WidgetEntityId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
|
||||
export function useResolvedSelectedInputs() {
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>([...(app.rootGraph?.nodes ?? [])])
|
||||
const refreshGraphNodes = () =>
|
||||
(graphNodes.value = [...(app.rootGraph?.nodes ?? [])])
|
||||
useEventListener(() => app.rootGraph?.events, 'configured', refreshGraphNodes)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'convert-to-subgraph',
|
||||
refreshGraphNodes
|
||||
)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'subgraph-created',
|
||||
refreshGraphNodes
|
||||
)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'node:slot-label:changed',
|
||||
() => triggerRef(graphNodes)
|
||||
)
|
||||
|
||||
return computed<ResolvedSelection[]>(() => {
|
||||
void graphNodes.value
|
||||
const rootGraph = app.rootGraph
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||