Compare commits

..

1 Commits

337 changed files with 13289 additions and 29299 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,3 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

1
.nxignore Normal file
View File

@@ -0,0 +1 @@
.claude/worktrees

View File

@@ -2,6 +2,7 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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). */

View File

@@ -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). */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,5 +19,12 @@
"devDependencies": {
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:design"
]
}
}

View File

@@ -15,5 +15,12 @@
},
"devDependencies": {
"@hey-api/openapi-ts": "0.93.0"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -20,5 +20,11 @@
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

View File

@@ -5,5 +5,12 @@
"type": "module",
"exports": {
".": "./src/comfyRegistryTypes.ts"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -18,5 +18,6 @@
},
"devDependencies": {
"typescript": "catalog:"
}
},
"packageManager": "pnpm@10.17.1"
}

View File

@@ -17,5 +17,11 @@
},
"devDependencies": {
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

3317
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 B

After

Width:  |  Height:  |  Size: 705 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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