Compare commits

..

18 Commits

Author SHA1 Message Date
Comfy Org PR Bot
bd85253dbe [backport cloud/1.43] fix: remove appendTo override from FormDropdown to restore correct positioning (#11222)
Backport of #11147 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11222-backport-cloud-1-43-fix-remove-appendTo-override-from-FormDropdown-to-restore-correct-3426d73d365081e0a459c0b50a67adcb)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-14 03:11:56 +00:00
Comfy Org PR Bot
8dd3ee072e [backport cloud/1.43] fix: check server feature flags for progress_text binary format (#11191)
Backport of #10996 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11191-backport-cloud-1-43-fix-check-server-feature-flags-for-progress_text-binary-format-3416d73d36508120abc9fbeeb1bbb447)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-13 11:52:07 -07:00
Comfy Org PR Bot
e49d1afa61 [backport cloud/1.43] fix: prevent node context menu from overflowing viewport on desktop (#11123)
Backport of #10854 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11123-backport-cloud-1-43-fix-prevent-node-context-menu-from-overflowing-viewport-on-deskto-33e6d73d365081af9e33d6c04d965e16)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-04-13 11:50:56 -07:00
Comfy Org PR Bot
c7943ca1b6 [backport cloud/1.43] fix: preserve CustomCombo options through clone and paste (#11125)
Backport of #10853 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11125-backport-cloud-1-43-fix-preserve-CustomCombo-options-through-clone-and-paste-33e6d73d3650819c827fe9387fe2b770)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-04-13 11:50:24 -07:00
Comfy Org PR Bot
9816951a39 [backport cloud/1.43] fix: debounce reconnecting toast to prevent false-positive banner (#11163)
Backport of #10997 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11163-backport-cloud-1-43-fix-debounce-reconnecting-toast-to-prevent-false-positive-banner-33f6d73d36508148854cd66118b0393c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-10 18:11:26 -07:00
Comfy Org PR Bot
e7c10aaf77 [backport cloud/1.43] fix: use standard size-4 for blueprint action icons (#11158)
Backport of #10992 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11158-backport-cloud-1-43-fix-use-standard-size-4-for-blueprint-action-icons-33f6d73d36508159921fd8f26e05c1f7)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-10 18:05:08 -07:00
Comfy Org PR Bot
1ddc0bb125 [backport cloud/1.43] fix: resolve lint/knip warnings and upgrade oxlint, oxfmt, knip (#11121)
Backport of #10973 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11121-backport-cloud-1-43-fix-resolve-lint-knip-warnings-and-upgrade-oxlint-oxfmt-knip-33e6d73d36508166a438fd292a7bb302)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 18:22:06 +00:00
Comfy Org PR Bot
fe8dc17d2d [backport cloud/1.43] fix: use || instead of ?? and server type in WebcamCapture upload path (#11005)
Backport of #11000 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11005-backport-cloud-1-43-fix-use-instead-of-and-server-type-in-WebcamCapture-upload--33d6d73d36508107866fc428296020c1)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-09 23:17:40 +00:00
Comfy Org PR Bot
352f5a0cd4 [backport cloud/1.43] fix: use cloud assets for asset widget default value (#10986)
Backport of #10983 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10986-backport-cloud-1-43-fix-use-cloud-assets-for-asset-widget-default-value-33d6d73d365081f0bb1ee04f90d87299)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-04-09 06:53:12 +00:00
Christian Byrne
c671a33182 fix(ci): resolve pnpm version conflict in version bump workflow (#10972)
## Summary

Removes hardcoded `version: 10` from `pnpm/action-setup` and instead
injects the `packageManager` field into `package.json` when absent
(legacy `core/*` branches).

## Why

PR #10952 re-added `version: 10` to fix old branches lacking
`packageManager`. But `main` now has **both** `version: 10` (workflow)
and `packageManager: pnpm@10.33.0` (`package.json`), causing
`pnpm/action-setup` to error with:

> Multiple versions of pnpm specified

Failed run:
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24158869559

This fix handles both cases:
- **`main`**: has `packageManager` → action reads it directly, no
conflict
- **`core/1.42` etc**: missing `packageManager` → step injects it before
the action runs

E2E test not applicable — this is a CI workflow configuration change
with no user-facing behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10972-fix-ci-resolve-pnpm-version-conflict-in-version-bump-workflow-33c6d73d36508112802df75c0dd5ea50)
by [Unito](https://www.unito.io)
2026-04-08 15:13:33 -07:00
Alexander Brown
25d1ac7456 test: reorganize subgraph test suite into composable domain specs (#10759)
## Summary

Reorganize the subgraph test suite so browser tests are thin
representative user journeys while lower-level Vitest suites own
combinatorics, migration edge cases, and data-shape semantics.

## Changes

- **What**: Migrate 17 flat subgraph browser specs into 10
domain-organized specs under `browser_tests/tests/subgraph/`, move
redundant semantic coverage down to 8 Vitest owner suites, delete all
legacy flat files
- **Browser specs** (54 tests): `subgraphSlots`, `subgraphPromotion`,
`subgraphPromotionDom`, `subgraphSerialization`, `subgraphNavigation`,
`subgraphNested`, `subgraphLifecycle`, `subgraphCrud`, `subgraphSearch`,
`subgraphOperations`
- **Vitest owners** (230 tests): `SubgraphNode.test.ts` (rename/label
propagation), `subgraphNodePromotion.test.ts`,
`promotedWidgetView.test.ts`, `SubgraphSerialization.test.ts`
(duplicate-ID remap), `SubgraphWidgetPromotion.test.ts` (legacy
hydration), `subgraphNavigationStore*.test.ts` (viewport cache,
workflow-switch), `subgraphStore.test.ts` (search aliases, description)
- **Net effect**: browser suite shrinks from ~96 scattered tests to 54
focused journeys

## Review Focus

- Coverage ownership split: each browser test has a unique UI-only
failure mode; semantic coverage lives in Vitest
- `subgraphPromotionDom.spec.ts` forces LiteGraph mode and uses
`canvas.openSubgraph()` instead of `navigateIntoSubgraph()` to avoid a
wrapper-specific DOM overlay duplication issue — entry-affordance
coverage lives in `subgraphNavigation.spec.ts`
- No product code changes — test-only migration

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10759-test-reorganize-subgraph-test-suite-into-composable-domain-specs-3336d73d365081b0a56bcbf809b1f584)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 15:04:33 -07:00
Johnpaul Chiwetelu
2189172f15 fix: Add timeout and abort mechanism for image upload (#9226) (#9491)
Closes #9226

## Summary

Image uploads had no timeout or abort mechanism, meaning a stalled
upload could hang indefinitely with no user feedback. This adds a
2-minute timeout using `AbortController` and shows a user-friendly toast
message when the upload times out.

## Changes

- `src/composables/node/useNodeImageUpload.ts`: Added `AbortController`
with a 120-second timeout to the `uploadFile` function. The abort signal
is passed to `fetchApi`. In the `handleUpload` error handler,
`AbortError` is now caught separately to display a localized timeout
message.
- `src/locales/en/main.json`: Added `uploadTimedOut` i18n translation
key.

---
Automated by coderabbit-fixer

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9491-fix-Add-timeout-and-abort-mechanism-for-image-upload-9226-31b6d73d365081d7a7d7f7016f3a71c6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: CodeRabbit Fixer <coderabbit-fixer@automated.bot>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-08 21:27:32 +00:00
Christian Byrne
9b769656ac test: add ShareWorkflow dialog E2E tests (DLG-05) (#10588)
## Summary
Adds Playwright E2E tests for the ShareWorkflow dialog component and its
various states.

## Tests added
- Dialog opens and shows unsaved state for new workflows (save prompt)
- Ready state shows create link button for saved but unpublished
workflows
- Shared state shows copy URL field with share link after publishing
- Stale state shows update link button when workflow modified after
publishing
- Close button dismisses dialog
- Create link transitions dialog from ready to shared state
- Tab switching between share link and publish to hub (when
comfyHubUploadEnabled)
- Tab aria-selected states update correctly on switch

## Approach
- Share dialog is gated behind `isCloud` (compile-time constant), so
tests invoke it directly via `page.evaluate()` importing
`useShareDialog`
- Share service API calls (`/api/userdata/*/publish`,
`/api/assets/from-workflow`) mocked via `page.route()` for deterministic
state testing
- Dialog state (loading → unsaved → ready → shared → stale) controlled
by mock responses
- Feature flags set via `serverFeatureFlags.value` for tab visibility
testing

## Notes
- All pre-existing TS2307 errors are `.vue` module resolution — no new
type errors
- Tests cover the 5 dialog states: loading, unsaved, ready, shared,
stale

## Task
Part of Test Coverage Q2 Overhaul (DLG-05).

## Conventions
- Uses Vue nodes with new menu enabled
- Tests read as user stories
- No full-page screenshots
- Proper waits, no sleeps
- All API calls mocked

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10588-test-add-ShareWorkflow-dialog-E2E-tests-DLG-05-3306d73d365081a0ab15f333707e493b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 14:14:47 -07:00
Christian Byrne
934f1487bd feat: add Comfy Design Standards Figma reference for agents (#10696)
## Summary

Add design standards instructions so agents consult the canonical [Comfy
Design
Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards)
Figma file before implementing user-facing features.

### Changes

- **`docs/guidance/design-standards.md`** — Auto-loaded guidance for
`src/components/**/*.vue` and `src/views/**/*.vue` with Figma MCP fetch
instructions, section node IDs, and component set references
- **`AGENTS.md`** — Added Design Standards section and Figma link in
External Resources

### Design

All content is fetched **live from Figma** via the Figma MCP tool —
designers can update the file and agents will always see the latest
version. No hardcoded design rules that can go stale.

Ref: COM-17639

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10696-feat-add-Comfy-Design-Standards-Figma-reference-for-agents-3326d73d36508181844fdcaa5c17cf00)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 14:13:57 -07:00
Christian Byrne
6f98fe5ba7 docs: add staging environment setup instructions to CONTRIBUTING.md (#10775)
## Summary

Add a "Testing with Cloud & Staging Environments" section to
CONTRIBUTING.md documenting how to test partner/API nodes that require
cloud backend authentication.

## Changes

- **What**: New section in CONTRIBUTING.md between "Dev Server" and
"Access dev server on touch devices" explaining two approaches for
staging/cloud development:
1. Frontend approach: `pnpm dev:cloud` or custom
`DEV_SERVER_COMFYUI_URL` in `.env`
2. Backend approach: `--comfy-api-base https://stagingapi.comfy.org`
flag

## Review Focus

- Accuracy of the `--comfy-api-base` backend flag documentation (sourced
from internal Slack discussion)
- Whether the section placement and level of detail is appropriate

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10775-docs-add-staging-environment-setup-instructions-to-CONTRIBUTING-md-3346d73d36508112bcd4df6ecb7f83c6)
by [Unito](https://www.unito.io)
2026-04-08 14:11:01 -07:00
Rizumu Ayaka
44c3d08b56 perf: add preload and content-visibility attribute to media preview for improved performance (#10806)
## Summary

this pull request is to improve the performance of large workflows
containing numerous Media Previews.

Added content-visibility: auto, which enables the browser to lazily
render Media Previews outside the viewport.

Added preload="metadata", which makes <video> and <audio> elements only
preload metadata instead of the full content.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10806-perf-add-preload-and-content-visibility-attribute-to-media-preview-for-improved-perfor-3356d73d365081238ce8f1a82f8694ec)
by [Unito](https://www.unito.io)
2026-04-08 14:08:47 -07:00
Dante
537e4bc4f2 test: add E2E tests for settings dialog (#10797)
## Summary
- Adds Playwright E2E tests for the Settings dialog covering behaviors
not tested elsewhere:
- About panel displays version badges (navigates to About, verifies
content)
- Boolean setting toggle through the UI persists the value (uses search
+ ToggleSwitch click, verifies via API)
- Dialog can be closed via the close button (complements existing
Escape-key test)

## Test plan
- [ ] `pnpm test:browser:local -- --grep "Settings dialog"` passes
- [ ] No overlap with existing tests in `dialog.spec.ts` or
`useSettingSearch.spec.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10797-test-add-E2E-tests-for-settings-dialog-3356d73d365081e4a3adcd3979048444)
by [Unito](https://www.unito.io)
2026-04-09 05:29:53 +09:00
pythongosssss
4b0b8e7240 test: App mode - Welcome screen state (#10747)
## Summary

Adds tests for validating welcome screen state

## Changes

- **What**: 
- add clear graph util function

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10747-test-App-mode-Welcome-screen-state-3336d73d365081f0ba27d567c3c81505)
by [Unito](https://www.unito.io)
2026-04-08 12:33:51 -07:00
61 changed files with 3027 additions and 2269 deletions

View File

@@ -142,10 +142,22 @@ jobs:
fi
echo "✅ Branch '$BRANCH' exists"
- name: Ensure packageManager field exists
run: |
if ! grep -q '"packageManager"' package.json; then
# Old branches (e.g. core/1.42) predate the packageManager field.
# Inject it so pnpm/action-setup can resolve the version.
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
pkg.packageManager = 'pnpm@10.33.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Injected packageManager into package.json for legacy branch"
fi
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -64,6 +64,7 @@
]
}
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
@@ -104,8 +105,7 @@
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
"vue/no-import-compiler-macros": "error"
},
"overrides": [
{

View File

@@ -179,6 +179,12 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
## Design Standards
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
## Testing Guidelines
See @docs/testing/\*.md for detailed patterns.
@@ -226,6 +232,7 @@ See @docs/testing/\*.md for detailed patterns.
- shadcn/vue: <https://www.shadcn-vue.com/>
- Reka UI: <https://reka-ui.com/>
- PrimeVue: <https://primevue.org>
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
@@ -311,6 +318,9 @@ When referencing Comfy-Org repos:
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules

View File

@@ -62,6 +62,37 @@ python main.py --port 8188 --cpu
- Run `pnpm dev:electron` to start the dev server with electron API mocked
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
#### Testing with Cloud & Staging Environments
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
**Option 1: Frontend — `pnpm dev:cloud`**
The simplest approach. This proxies all API requests to the test cloud environment:
```bash
pnpm dev:cloud
```
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
```bash
# .env
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
```
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
**Option 2: Backend — `--comfy-api-base`**
Alternatively, launch the ComfyUI backend pointed at the staging API:
```bash
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
```
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.

View File

@@ -34,6 +34,7 @@ import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
@@ -181,6 +182,7 @@ export class ComfyPage {
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -232,6 +234,7 @@ export class ComfyPage {
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
get visibleToasts() {
@@ -444,6 +447,10 @@ export const comfyPageFixture = base.extend<{
console.error(e)
}
if (testInfo.tags.includes('@cloud')) {
await comfyPage.cloudAuth.mockAuth()
}
await comfyPage.setup()
const needsPerf =

View File

@@ -53,9 +53,8 @@ export class AppModeHelper {
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await this.comfyPage.workflow.waitForActiveWorkflow()
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
await this.comfyPage.nextFrame()
}
@@ -124,6 +123,31 @@ export class AppModeHelper {
.getByRole('button', { name: /run/i })
}
/** The welcome screen shown when app mode has no outputs or no nodes. */
get welcome(): Locator {
return this.page.getByTestId(TestIds.appMode.welcome)
}
/** The empty workflow message shown when no nodes exist. */
get emptyWorkflowText(): Locator {
return this.page.getByTestId(TestIds.appMode.emptyWorkflow)
}
/** The "Build app" button shown when nodes exist but no outputs. */
get buildAppButton(): Locator {
return this.page.getByTestId(TestIds.appMode.buildApp)
}
/** The "Back to workflow" button on the welcome screen. */
get backToWorkflowButton(): Locator {
return this.page.getByTestId(TestIds.appMode.backToWorkflow)
}
/** The "Load template" button shown when no nodes exist. */
get loadTemplateButton(): Locator {
return this.page.getByTestId(TestIds.appMode.loadTemplate)
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

@@ -0,0 +1,171 @@
import type { Page } from '@playwright/test'
/**
* Mocks Firebase authentication for cloud E2E tests.
*
* The cloud build's router guard waits for Firebase `onAuthStateChanged`
* to fire, then checks `getAuthHeader()`. In CI no Firebase project is
* configured, so the user is never authenticated and the app redirects
* to `/cloud/login`.
*
* This helper seeds Firebase's IndexedDB persistence layer with a mock
* user and intercepts the Firebase REST APIs (securetoken, identitytoolkit)
* so the SDK believes a user is signed in. Must be called before navigation.
*/
export class CloudAuthHelper {
constructor(private readonly page: Page) {}
/**
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
*/
async mockAuth(): Promise<void> {
await this.seedFirebaseIndexedDB()
await this.mockFirebaseEndpoints()
}
/**
* Navigate to a lightweight same-origin page to seed Firebase's
* IndexedDB persistence with a mock user. This ensures the data
* is written before the app loads and Firebase reads it.
*
* Firebase auth uses `browserLocalPersistence` which stores data in
* IndexedDB database `firebaseLocalStorageDb`, object store
* `firebaseLocalStorage`, keyed by `firebase:authUser:<apiKey>:<appName>`.
*/
private async seedFirebaseIndexedDB(): Promise<void> {
// Navigate to a lightweight endpoint to get a same-origin context
await this.page.goto('http://localhost:8188/api/users')
await this.page.evaluate(() => {
const MOCK_USER_DATA = {
uid: 'test-user-e2e',
email: 'e2e@test.comfy.org',
displayName: 'E2E Test User',
emailVerified: true,
isAnonymous: false,
providerData: [
{
providerId: 'google.com',
uid: 'test-user-e2e',
displayName: 'E2E Test User',
email: 'e2e@test.comfy.org',
phoneNumber: null,
photoURL: null
}
],
stsTokenManager: {
refreshToken: 'mock-refresh-token',
accessToken: 'mock-firebase-id-token',
expirationTime: Date.now() + 60 * 60 * 1000
},
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
appName: '[DEFAULT]'
}
const DB_NAME = 'firebaseLocalStorageDb'
const STORE_NAME = 'firebaseLocalStorage'
const KEY = `firebase:authUser:${MOCK_USER_DATA.apiKey}:${MOCK_USER_DATA.appName}`
return new Promise<void>((resolve, reject) => {
const request = indexedDB.open(DB_NAME)
request.onerror = () => reject(request.error)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.close()
const upgradeReq = indexedDB.open(DB_NAME, db.version + 1)
upgradeReq.onerror = () => reject(upgradeReq.error)
upgradeReq.onupgradeneeded = () => {
const upgradedDb = upgradeReq.result
if (!upgradedDb.objectStoreNames.contains(STORE_NAME)) {
upgradedDb.createObjectStore(STORE_NAME)
}
}
upgradeReq.onsuccess = () => {
const upgradedDb = upgradeReq.result
const tx = upgradedDb.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(
{ fpiVersion: '1', value: MOCK_USER_DATA },
KEY
)
tx.oncomplete = () => {
upgradedDb.close()
resolve()
}
tx.onerror = () => reject(tx.error)
}
return
}
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(
{ fpiVersion: '1', value: MOCK_USER_DATA },
KEY
)
tx.oncomplete = () => {
db.close()
resolve()
}
tx.onerror = () => reject(tx.error)
}
})
})
}
/**
* Intercept Firebase Auth REST API endpoints so the SDK can
* "refresh" the mock user's token without real credentials.
*/
private async mockFirebaseEndpoints(): Promise<void> {
await this.page.route('**/securetoken.googleapis.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
access_token: 'mock-access-token',
expires_in: '3600',
token_type: 'Bearer',
refresh_token: 'mock-refresh-token',
id_token: 'mock-firebase-id-token',
user_id: 'test-user-e2e',
project_id: 'dreamboothy-dev'
})
})
)
await this.page.route('**/identitytoolkit.googleapis.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
kind: 'identitytoolkit#GetAccountInfoResponse',
users: [
{
localId: 'test-user-e2e',
email: 'e2e@test.comfy.org',
displayName: 'E2E Test User',
emailVerified: true,
validSince: '0',
lastLoginAt: String(Date.now()),
createdAt: String(Date.now()),
lastRefreshAt: new Date().toISOString()
}
]
})
})
)
await this.page.route('**/__/auth/**', (route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html><body></body></html>'
})
)
}
}

View File

@@ -30,6 +30,12 @@ export class NodeOperationsHelper {
})
}
/** Remove all nodes from the graph and clean. */
async clearGraph() {
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await this.comfyPage.command.executeCommand('Comfy.ClearWorkflow')
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)

View File

@@ -116,6 +116,14 @@ export class WorkflowHelper {
})
}
async waitForActiveWorkflow(): Promise<void> {
await this.comfyPage.page.waitForFunction(
() =>
(window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow !== null
)
}
async getActiveWorkflowPath(): Promise<string | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow

View File

@@ -63,7 +63,8 @@ export const TestIds = {
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button'
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -130,7 +131,12 @@ export const TestIds = {
connectOutputPopover: 'builder-connect-output-popover'
},
appMode: {
widgetItem: 'app-mode-widget-item'
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
loadTemplate: 'linear-welcome-load-template'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'

View File

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

View File

@@ -0,0 +1,61 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('App mode welcome states', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Empty workflow text is visible when no nodes', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
})
test('Build app button is visible when no outputs selected', async ({
comfyPage
}) => {
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
})
test('Empty workflow and build app are hidden when app has outputs', async ({
comfyPage
}) => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
})
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.backToWorkflowButton.click()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).not.toBeVisible()
})
test('Load template opens template selector', async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.loadTemplateButton.click()
await expect(comfyPage.templates.content).toBeVisible()
})
})

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('should use first cloud asset when server default is not in assets', async ({
comfyPage
}) => {
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
{ timeout: 10_000 }
)
.toBe('asset')
// Add a new CheckpointLoaderSimple — should use first cloud asset,
// not the server's object_info default.
const widgetValue = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
window.app!.graph.add(node!)
const widget = node!.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
})
// Production resolves via getAssetFilename (user_metadata.filename →
// metadata.filename → asset.name). Test fixtures have no metadata
// filename, so asset.name is the resolved value.
expect(widgetValue).toBe(CLOUD_ASSETS[0].name)
})
})

View File

@@ -0,0 +1,166 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { mockSystemStats } from '../../fixtures/data/systemStats'
const MOCK_COMFYUI_VERSION = '9.99.0-e2e-test'
test.describe('Settings dialog', { tag: '@ui' }, () => {
test('About panel renders mocked version from server', async ({
comfyPage
}) => {
const stats = {
...mockSystemStats,
system: {
...mockSystemStats.system,
comfyui_version: MOCK_COMFYUI_VERSION
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({ json: stats })
})
await comfyPage.setup()
const dialog = comfyPage.settingDialog
await dialog.open()
await dialog.goToAboutPanel()
const aboutPanel = comfyPage.page.getByTestId('about-panel')
await expect(aboutPanel).toBeVisible()
await expect(aboutPanel).toContainText(MOCK_COMFYUI_VERSION)
await expect(aboutPanel).toContainText('ComfyUI_frontend')
})
test('Toggling a boolean setting through UI persists the value', async ({
comfyPage
}) => {
const settingId = 'Comfy.Validation.Workflows'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
const dialog = comfyPage.settingDialog
await dialog.open()
try {
await dialog.searchBox.fill('Validate workflows')
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
await settingRow.locator('.p-toggleswitch').click()
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Can be closed via close button', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
await expect(dialog.root).toBeVisible()
await dialog.close()
await expect(dialog.root).not.toBeVisible()
})
test('Escape key closes dialog', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
await expect(dialog.root).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).not.toBeVisible()
})
test('Search filters settings list', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const settingItems = dialog.root.locator('[data-setting-id]')
const countBeforeSearch = await settingItems.count()
await dialog.searchBox.fill('Validate workflows')
await expect
.poll(() => settingItems.count())
.toBeLessThan(countBeforeSearch)
})
test('Search can be cleared to restore all settings', async ({
comfyPage
}) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const settingItems = dialog.root.locator('[data-setting-id]')
const countBeforeSearch = await settingItems.count()
await dialog.searchBox.fill('Validate workflows')
await expect
.poll(() => settingItems.count())
.toBeLessThan(countBeforeSearch)
await dialog.searchBox.clear()
await expect.poll(() => settingItems.count()).toBe(countBeforeSearch)
})
test('Category navigation changes content area', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const firstCategory = dialog.categories.first()
const firstCategoryName = await firstCategory.textContent()
await firstCategory.click()
const firstContent = await dialog.contentArea.textContent()
// Find a different category to click
const categoryCount = await dialog.categories.count()
let switched = false
for (let i = 1; i < categoryCount; i++) {
const cat = dialog.categories.nth(i)
const catName = await cat.textContent()
if (catName !== firstCategoryName) {
await cat.click()
await expect
.poll(() => dialog.contentArea.textContent())
.not.toBe(firstContent)
switched = true
break
}
}
expect(switched).toBe(true)
})
test('Dropdown setting can be changed and persists', async ({
comfyPage
}) => {
const settingId = 'Comfy.UseNewMenu'
const initialValue = await comfyPage.settings.getSetting<string>(settingId)
const dialog = comfyPage.settingDialog
await dialog.open()
try {
await dialog.searchBox.fill('Use new menu')
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Click the PrimeVue Select to open the dropdown
await settingRow.locator('.p-select').click()
const overlay = comfyPage.page.locator('.p-select-overlay')
await expect(overlay).toBeVisible()
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
await overlay
.locator(`.p-select-option-label:text-is("${targetValue}")`)
.click()
await expect
.poll(() => comfyPage.settings.getSetting<string>(settingId))
.toBe(targetValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
})

View File

@@ -0,0 +1,352 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { AssetInfo } from '../../../src/schemas/apiSchema'
import { comfyPageFixture } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
interface PublishRecord {
workflow_id: string
share_id: string | null
listed: boolean
publish_time: string | null
}
const PUBLISHED_RECORD: PublishRecord = {
workflow_id: 'wf-1',
share_id: 'share-abc',
listed: false,
publish_time: new Date(Date.now() + 60_000).toISOString()
}
const PRIVATE_ASSET: AssetInfo = {
id: 'asset-1',
name: 'photo.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const test = comfyPageFixture
/**
* Enable the workflow_sharing_enabled server feature flag at runtime.
* FeatureFlagHelper.mockServerFeatures() intercepts `/api/features` but the
* flags are already loaded by the time tests run, so direct mutation of the
* reactive ref is the only reliable approach for server-side flags.
*/
async function enableWorkflowSharing(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
workflow_sharing_enabled: true
}
})
}
async function mockPublishStatus(
page: Page,
record: PublishRecord | null
): Promise<void> {
await page.route('**/api/userdata/*/publish', async (route) => {
if (route.request().method() === 'GET') {
if (!record || !record.share_id) {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(record)
})
}
} else {
await route.fallback()
}
})
}
async function mockPublishWorkflow(
page: Page,
result: PublishRecord
): Promise<void> {
await page.route('**/api/userdata/*/publish', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(result)
})
} else {
await route.fallback()
}
})
}
async function mockShareableAssets(
page: Page,
assets: AssetInfo[] = []
): Promise<void> {
await page.route('**/api/assets/from-workflow', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ assets })
})
})
}
/**
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
* or auth-triggered modals by pressing Escape until they clear.
*/
async function dismissOverlays(page: Page): Promise<void> {
const mask = page.locator('.p-dialog-mask')
for (let attempt = 0; attempt < 3; attempt++) {
if ((await mask.count()) === 0) break
await page.keyboard.press('Escape')
await mask
.first()
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {})
}
}
/**
* Save the active workflow via the topbar Save menu action.
* Mocks the userdata POST endpoint to avoid real server calls in tests.
*/
async function saveAndWait(
comfyPage: {
page: Page
menu: { topbar: { saveWorkflow: (name: string) => Promise<void> } }
},
workflowName: string
): Promise<void> {
const filename =
workflowName + (workflowName.endsWith('.json') ? '' : '.json')
await comfyPage.page.route(
/\/api\/userdata\/workflows(%2F|\/).*$/,
async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
path: `workflows/${filename}`,
size: 1024,
modified: Date.now()
})
})
} else {
await route.fallback()
}
}
)
await comfyPage.menu.topbar.saveWorkflow(workflowName)
}
async function openShareDialog(page: Page): Promise<void> {
await enableWorkflowSharing(page)
await dismissOverlays(page)
const shareButton = page.getByRole('button', { name: 'Share workflow' })
await shareButton.click()
}
function getShareDialog(page: Page) {
return page.getByRole('dialog')
}
test.describe('Share Workflow Dialog', { tag: '@cloud' }, () => {
test('should show unsaved state for a new workflow', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /save workflow/i })
).toBeVisible()
})
test('should show ready state with create link button', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-ready'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /create a link/i })
).toBeVisible()
})
test('should show shared state with copy URL after publishing', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-shared'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, PUBLISHED_RECORD)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('textbox', { name: /share.*url/i })
).toBeVisible()
})
test('should show stale state with update link button', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-stale'
await saveAndWait(comfyPage, workflowName)
const staleRecord: PublishRecord = {
...PUBLISHED_RECORD,
publish_time: '2020-01-01T00:00:00Z'
}
await mockPublishStatus(page, staleRecord)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /update\s+link/i })
).toBeVisible()
})
test('should close dialog when close button is clicked', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: /close/i }).click()
await expect(dialog).toBeHidden()
})
test('should create link and transition to shared state', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-create'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await mockPublishWorkflow(page, PUBLISHED_RECORD)
await openShareDialog(page)
const dialog = getShareDialog(page)
const createButton = dialog.getByRole('button', { name: /create a link/i })
await expect(createButton).toBeVisible()
await createButton.click()
await expect(
dialog.getByRole('textbox', { name: /share.*url/i })
).toBeVisible()
})
test('should show tab buttons when comfyHubUploadEnabled is true', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
comfyhub_upload_enabled: true
}
})
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('tab', { name: /share/i })).toBeVisible()
await expect(dialog.getByRole('tab', { name: /publish/i })).toBeVisible()
})
test('should switch between share link and publish tabs', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
comfyhub_upload_enabled: true
}
})
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await dialog.getByRole('tab', { name: /publish/i }).click()
const publishPanel = dialog.getByTestId(TestIds.dialogs.publishTabPanel)
await expect(publishPanel).toBeVisible()
await dialog.getByRole('tab', { name: /share/i }).click()
await expect(publishPanel).toBeHidden()
})
test('should require acknowledgment before publishing private assets', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-ack'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page, [PRIVATE_ASSET])
await openShareDialog(page)
const dialog = getShareDialog(page)
const createButton = dialog.getByRole('button', { name: /create a link/i })
await expect(createButton).toBeDisabled()
await dialog.getByRole('checkbox').check()
await expect(createButton).toBeEnabled()
})
})

View File

@@ -335,7 +335,7 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.nextFrame()
await comfyPage.delay(1000)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNodeToggler
})
@@ -358,13 +358,14 @@ test.describe('Node Interaction', () => {
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
// The dialog registers its click-outside handler via setTimeout(10ms)
// with a 256ms debounce (see LGraphCanvas.prompt). dispatchEvent
// bypasses the canvas z-999 overlay that blocks normal clicks.
await expect(async () => {
await comfyPage.canvas.dispatchEvent('click', { bubbles: true })
await expect(legacyPrompt).toBeHidden({ timeout: 200 })
}).toPass({ intervals: [300, 500, 1000], timeout: 5_000 })
await comfyPage.delay(300)
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
})
test('Can close prompt dialog with canvas click (text widget)', async ({
@@ -380,10 +381,14 @@ test.describe('Node Interaction', () => {
})
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeVisible()
await expect(async () => {
await comfyPage.canvas.dispatchEvent('click', { bubbles: true })
await expect(legacyPrompt).toBeHidden({ timeout: 200 })
}).toPass({ intervals: [300, 500, 1000], timeout: 5_000 })
await comfyPage.delay(300)
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(legacyPrompt).toBeHidden()
})
test(

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.describe(
'Node context menu viewport overflow (#10824)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler toward the lower-left so the menu has limited space below it.
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()!
const centerX = viewportSize.width / 3
const centerY = viewportSize.height * 0.75
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible({ timeout: 3000 })
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
await comfyPage.nextFrame()
return menu
}
test('last menu item "Remove" is reachable via scroll', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const rootList = menu.locator(':scope > ul')
await expect
.poll(
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
{
message:
'Menu should overflow vertically so this test exercises the viewport clamp',
timeout: 3000
}
)
.toBe(true)
// "Remove" is the last item in the More Options menu.
// It must become reachable by scrolling the bounded menu list.
const removeItem = menu.getByText('Remove', { exact: true })
const didScroll = await rootList.evaluate((el) => {
const previousScrollTop = el.scrollTop
el.scrollTo({ top: el.scrollHeight })
return el.scrollTop > previousScrollTop
})
expect(didScroll).toBe(true)
await expect(removeItem).toBeVisible()
})
test('last menu item "Remove" is clickable and removes the node', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const removeItem = menu.getByText('Remove', { exact: true })
await removeItem.scrollIntoViewIfNeeded()
await removeItem.click()
await comfyPage.nextFrame()
// The node should be removed from the graph
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
.toBe(0)
})
}
)

View File

@@ -1,19 +1,34 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Constants
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
async function duplicateSubgraphNodeViaAltDrag(
comfyPage: ComfyPage
): Promise<void> {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const subgraphPos = await subgraphNode.getPosition()
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
}
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
@@ -37,18 +52,14 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(i) => i.link != null
(input) => input.link != null
).length
return { linkCount, linkedInputCount, nodeCount: nodes.length }
})
expect(result).not.toHaveProperty('error')
// Should have exactly 1 link (EmptyLatentImage→KSampler)
// not 4 (with 3 duplicates). The KSampler→output link is dropped
// because the subgraph output has no downstream connection.
expect(result.linkCount).toBe(1)
// KSampler should have exactly 1 linked input (latent_image)
expect(result.linkedInputCount).toBe(1)
})
})
@@ -62,14 +73,15 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
const node = await comfyPage.nodeOps.getNodeRefById('5')
await node.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(1)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(1)
await expect.poll(() => comfyPage.subgraph.getNodeCount()).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
@@ -82,69 +94,47 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.delete()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount - 1)
await expect.poll(() => deletedNode.exists()).toBe(false)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
test.describe('Subgraph Copy', () => {
test('Can duplicate a subgraph node by alt-dragging', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await duplicateSubgraphNodeViaAltDrag(comfyPage)
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect a second subgraph node to be created (2 total)
expect(subgraphNodes.length).toBe(2)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
test('Alt-dragging a subgraph node creates a new subgraph type', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await duplicateSubgraphNodeViaAltDrag(comfyPage)
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes and expect all unique IDs
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect the second subgraph node to have a unique type
const nodeType1 = await subgraphNodes[0].getType()
const nodeType2 = await subgraphNodes[1].getType()
expect(nodeType1).not.toBe(nodeType2)

View File

@@ -2,209 +2,91 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '@e2e/helpers/promotedWidgets'
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: 0,
firstWidgetType: undefined
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
await comfyPage.subgraph.exitViaBreadcrumb()
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
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 ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('5')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Removing the preview subgraph clears promoted preview state and DOM', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
})

View File

@@ -3,15 +3,8 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
// Constants
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
nodeSearchContainer: '.node-search-container'
} as const
function hasVisibleNodeInViewport() {
const canvas = window.app!.canvas
if (!canvas?.graph?._nodes?.length) return false
@@ -39,15 +32,7 @@ function hasVisibleNodeInViewport() {
}
test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Breadcrumb and Workflow Context', () => {
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
@@ -62,18 +47,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Navigate into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 20000
})
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
@@ -92,59 +71,40 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(breadcrumb).toBeVisible()
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context', async ({
test('Switching workflows while inside subgraph returns to root graph context and hides the breadcrumb', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
await expect(backButton).toHaveCount(0)
})
})
@@ -157,7 +117,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
@@ -182,28 +141,26 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
// Verify we're in a subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
expect(
await comfyPage.subgraph.isInSubgraph(),
'Escape should stay inside the subgraph after the default binding is unset'
).toBe(true)
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
@@ -217,39 +174,36 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
// Verify we're in a subgraph
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
expect(
await comfyPage.subgraph.isInSubgraph(),
'Precondition failed: expected to be inside the subgraph before opening settings'
).toBe(true)
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.locator('[data-testid="settings-dialog"]')
).not.toBeVisible()
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeHidden()
// Should still be in subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
test.describe('Viewport', () => {
test.describe('Subgraph viewport restoration', () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
@@ -258,24 +212,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph!
const sgNode = graph._nodes.find((n) =>
'isSubgraphNode' in n
? (
n as unknown as { isSubgraphNode: () => boolean }
).isSubgraphNode()
: false
) as unknown as { subgraph?: typeof graph } | undefined
if (!sgNode?.subgraph) throw new Error('No subgraph node')
canvas.setGraph(sgNode.subgraph)
})
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
timeout: 2_000
})
.toBe(true)
})
@@ -293,7 +235,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
timeout: 2_000
})
.toBe(true)
})
@@ -324,7 +266,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2000 }
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
@@ -336,61 +278,43 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
})
test.describe('Progress State', () => {
test.describe('Subgraph progress clear on navigation', () => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
await expect
.poll(
() =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId),
{ timeout: 2_000 }
)
.toBeUndefined()
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
@@ -418,21 +342,23 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(node) =>
typeof node.isSubgraphNode === 'function' &&
node.isSubgraphNode()
)
if (!subgraphNode) return { exists: false, progress: null }
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
return { exists: true, progress: subgraphNode.progress }
}),
{ timeout: 5_000 }
)
.toEqual({ exists: true, progress: undefined })
})
})
})

View File

@@ -4,177 +4,44 @@ import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe('Nested subgraph configure order', () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
test('Loads and queues without nested promotion resolution failures', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page,
['No link found', 'Failed to resolve legacy -1']
)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
expect(warnings).toEqual([])
})
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
test('All three subgraph levels resolve promoted widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const results = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
return allGraphs.flatMap((g) =>
g._nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((hostNode) => {
const proxyWidgets = Array.isArray(
hostNode.properties?.proxyWidgets
)
? hostNode.properties.proxyWidgets
: []
const widgetEntries = proxyWidgets
.filter(
(e: unknown): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string'
)
.map(([interiorNodeId, widgetName]: [string, string]) => {
const sg = hostNode.isSubgraphNode()
? hostNode.subgraph
: null
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
return {
interiorNodeId,
widgetName,
resolved:
interiorNode !== null && interiorNode !== undefined
}
})
return {
hostNodeId: String(hostNode.id),
widgetEntries
}
})
)
})
expect(
results.length,
'Should have subgraph host nodes at multiple nesting levels'
).toBeGreaterThanOrEqual(2)
for (const { hostNodeId, widgetEntries } of results) {
expect(
widgetEntries.length,
`Host node ${hostNodeId} should have promoted widgets`
).toBeGreaterThan(0)
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
expect(widgetName).toBeTruthy()
expect(
resolved,
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
).toBe(true)
}
const response = await responsePromise
expect(warnings).toEqual([])
expect(response.ok()).toBe(true)
} finally {
dispose()
}
})
test('Prompt execution succeeds without 400 error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const response = await responsePromise
expect(response.status()).not.toBe(400)
})
})
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = 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 innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (
(innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[]
)
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
comfyExpect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
@@ -210,60 +77,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await comfyExpect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await comfyExpect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)
/**
* Regression test for PR #10532:
* Packing all nodes inside a subgraph into a nested subgraph was causing
* the parent subgraph node's promoted widget values to go blank.
*
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
* at promotion time). After repointing, input._widget still pointed to
* removed node IDs, causing missing-node failures and blank values on the
* next checkState cycle.
*/
test.describe(
'Nested subgraph pack preserves promoted widget values',
{ tag: ['@widget'] },
@@ -284,7 +100,6 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
@@ -310,10 +125,8 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
await comfyExpect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
@@ -345,87 +158,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
await comfyExpect(textAfter).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
})
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Verify the host node is visible
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
// Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// Verify all proxyWidgets entries resolve
await comfyExpect(async () => {
const result = await comfyPage.page.evaluate((hostId) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
) {
return { error: 'Host node not found or not a subgraph node' }
}
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
const entries = (proxyWidgets as unknown[])
.filter(
(e): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string' &&
!e[1].startsWith('$$')
)
.map(([nodeId, widgetName]) => {
const interiorNode = hostNode.subgraph.getNodeById(
Number(nodeId)
)
return {
nodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return { entries, count: entries.length }
}, HOST_NODE_ID)
expect(result).not.toHaveProperty('error')
const { entries, count } = result as {
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
count: number
}
expect(count).toBeGreaterThan(0)
for (const entry of entries) {
expect(
entry.resolved,
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
).toBe(true)
}
}).toPass({ timeout: 5000 })
})
}
)
/**
* Regression test for nested subgraph packing leaving stale proxyWidgets
* on the outer SubgraphNode.
*
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
* Only ["3","seed"] (KSampler) should remain.
*
* Stale entries render as "Disconnected" placeholder widgets (type "button").
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
*/
test.describe(
'Nested subgraph stale proxyWidgets',
{ tag: ['@widget'] },
@@ -447,12 +182,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
// Only the KSampler seed widget should be present — no stale
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
await comfyExpect(widgets).toHaveCount(1)
await comfyExpect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
})

View File

@@ -2,19 +2,19 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Subgraph Internal 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 Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
test.describe('Subgraph Clipboard Operations', () => {
test('Can copy and paste nodes inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
@@ -22,45 +22,43 @@ test.describe(
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodeId = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id || null
})
expect(nodeId).not.toBeNull()
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
String(nodesInSubgraph)
)
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount + 1)
})
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
test.describe('Subgraph History Operations', () => {
test('Can undo and redo operations inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Add a node
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await comfyPage.subgraph.getNodeCount()
// Undo
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
@@ -68,7 +66,6 @@ test.describe(
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
@@ -76,5 +73,5 @@ test.describe(
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount)
})
}
)
})
})

View File

@@ -47,25 +47,20 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select just the KSampler node (id 3) which has a "seed" widget
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// SubgraphNode should exist
expect(await subgraphNode.exists()).toBe(true)
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
// SubgraphNode should have widgets (promoted views)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
test('Preview-capable nodes keep regular and pseudo-widget promotions when converted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
@@ -113,29 +108,12 @@ test.describe(
)
await comfyPage.nextFrame()
// The subgraph node (id 11) should have a text widget promoted
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
test('Multiple promoted widgets all render on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Visibility in Vue Mode', () => {
@@ -143,7 +121,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -151,57 +129,26 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// SubgraphNode (id 11) should render with its body
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
// It should have the Enter Subgraph button
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
// The promoted text widget should render inside the node
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
await expect(nodeBody).toBeVisible()
// Widgets section should exist and have at least one widget
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
})
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const widgets = nodeBody.locator('.lg-node-widgets > div')
const count = await widgets.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Reactivity', () => {
test('Value changes on promoted widget sync to interior widget', async ({
test('Promoted and interior widgets stay in sync across navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -211,87 +158,30 @@ test.describe(
const testContent = 'promoted-value-sync-test'
// Type into the promoted textarea on the SubgraphNode
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Interior CLIPTextEncode textarea should have the same value
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
})
test('Value changes on interior widget sync to promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
await comfyPage.nextFrame()
const testContent = 'interior-value-sync-test'
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Type into the interior CLIPTextEncode textarea
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await interiorTextarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
})
test('Value persists through repeated navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'persistence-through-navigation'
// Set value on promoted widget
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
// Navigate in and out multiple times
for (let i = 0; i < 3; i++) {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
}
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
})
})
@@ -300,7 +190,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can promote a widget from inside a subgraph', async ({
test('Can promote and un-promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -308,10 +198,9 @@ test.describe(
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get the KSampler node (id 1) inside the subgraph
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler.click('title')
// Right-click on the KSampler's "steps" widget (index 2) to promote it
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
@@ -360,12 +249,13 @@ test.describe(
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
@@ -373,12 +263,11 @@ test.describe(
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode2.navigateIntoSubgraph()
const stepsWidget2 = await (
await comfyPage.nodeOps.getNodeRefById('1')
).getWidget(2)
const ksampler2 = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler2.click('title')
const stepsWidget2 = await ksampler2.getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
@@ -396,10 +285,8 @@ test.describe(
await unpromoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
timeout: 5000
@@ -422,26 +309,16 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// Navigate into the subgraph (node id 11)
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// The interior CLIPTextEncode node (id 10) should render a textarea
// widget in Vue mode. Right-click it to verify the contextmenu
// event propagates correctly (fix from PR #9840) and shows the
// ComfyUI context menu with "Promote Widget".
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
// Select the node first so the context menu builds correctly
await comfyPage.vueNodes.selectNode('10')
await comfyPage.nextFrame()
// Dispatch a contextmenu event directly on the textarea. A normal
// right-click is intercepted by the z-999 canvas overlay, but the
// Vue WidgetTextarea.vue handler listens on @contextmenu.capture,
// so dispatching the event directly tests the fix from PR #9840.
const textarea = clipNode.locator('textarea')
await expect(textarea).toBeVisible()
await textarea.dispatchEvent('contextmenu', {
@@ -451,8 +328,6 @@ test.describe(
})
await comfyPage.nextFrame()
// The PrimeVue context menu should show "Promote Widget" since
// the node is inside a subgraph (not the root graph).
const promoteEntry = comfyPage.page
.locator('.p-contextmenu')
.locator('text=Promote Widget')
@@ -462,7 +337,7 @@ test.describe(
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
test('Promoted preview nodes render custom content in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -506,11 +381,6 @@ test.describe(
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
@@ -519,14 +389,35 @@ test.describe(
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
// The node body should exist
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('filename_prefix')
expect(promotedNames.some((name) => name.startsWith('$$'))).toBe(true)
const loadImageNode = await comfyPage.nodeOps.getNodeRefById('11')
const loadImagePosition = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: loadImagePosition
})
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
await expect(
nodeBody.locator('.lg-node-widgets > div').first()
).toBeVisible()
await expect(nodeBody.locator('.image-preview img')).toHaveCount(1, {
timeout: 30_000
})
await expect(nodeBody.locator('.lg-node-widgets')).not.toContainText(
'$$canvas-image-preview'
)
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -534,10 +425,6 @@ test.describe(
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
@@ -553,29 +440,12 @@ test.describe(
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
@@ -588,8 +458,11 @@ test.describe(
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
}
expect(editedTextarea).toBe(true)
})
})

View File

@@ -1,218 +1,170 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
// Constants
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'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
domWidget: '.comfy-multiline-input'
} as const
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
await comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.rootGraph.nodes.find(
(candidate) => String(candidate.id) === targetNodeId
)
if (!node || !('subgraph' in node) || !node.subgraph) {
throw new Error(`Subgraph node ${targetNodeId} not found`)
}
test.describe(
'Subgraph Promoted Widget DOM',
{ tag: ['@slow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
window.app!.canvas.openSubgraph(node.subgraph, node)
}, nodeId)
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
}),
{ timeout: 5_000 }
)
.toBe(true)
}
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).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
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
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')
expect(await subgraphNode.exists()).toBe(true)
await openSubgraphById(comfyPage, '11')
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
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('DOM Widget Navigation and Persistence', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await subgraphNode.navigateIntoSubgraph()
// Verify widget is visible in subgraph
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
// Navigate back
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Verify widget is still visible
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
})
test('DOM widget content is preserved through navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
await textarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const parentCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(subgraphCount).toBe(parentCount)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(parentCount)
})
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
})
test.describe('DOM Cleanup', () => {
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
await subgraphNode.delete()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
await openSubgraphById(comfyPage, '11')
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
// Enable new menu for breadcrumb navigation
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.subgraph.removeSlot('input', 'text')
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.subgraph.exitViaBreadcrumb()
const textareaCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(textareaCount).toBe(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.removeSlot('input', 'text')
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 5000
})
// Click breadcrumb to navigate back to parent graph
const homeBreadcrumb = comfyPage.page.locator(
'.p-breadcrumb-list > :first-child'
)
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
// Check that the subgraph node has no widgets after removing the text slot
const widgetCount = await comfyPage.page.evaluate(() => {
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
})
expect(widgetCount).toBe(0)
})
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
test.describe('DOM Positioning', () => {
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const parentCount = await comfyPage.page
.locator(VISIBLE_DOM_WIDGET_SELECTOR)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNodeId = String(subgraphNode.id)
await expect(async () => {
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
}).toPass({ timeout: 5000 })
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
await openSubgraphById(comfyPage, '11')
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator
.getByLabel('seed', { exact: true })
.first()
await expect(seedWidget).toBeVisible()
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify widget is inside the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
})
}
)
})
})

View File

@@ -23,7 +23,7 @@ async function exitSubgraphAndPublish(
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5_000 })
await comfyPage.toast.closeToasts(1)
}
@@ -36,7 +36,7 @@ async function searchAndExpectResult(
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.input.fill(searchTerm)
await expect(comfyPage.searchBox.findResult(expectedResult)).toBeVisible({
timeout: 10000
timeout: 10_000
})
}
@@ -49,35 +49,22 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
)
})
test('Can set search aliases on subgraph and find via search', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetSearchAliases', {
aliases: 'qwerty,unicorn'
})
const blueprintName = `test-aliases-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
await searchAndExpectResult(comfyPage, 'unicorn', blueprintName)
})
test('Can set description on subgraph', async ({ comfyPage }) => {
await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetDescription', {
description: 'This is a test description'
})
// Verify the description was set on the subgraph's extra
const description = await comfyPage.page.evaluate(() => {
const subgraph = window['app']!.canvas.subgraph
const subgraph = window.app!.canvas.subgraph
return (subgraph?.extra as Record<string, unknown>)?.BlueprintDescription
})
expect(description).toBe('This is a test description')
})
test('Search aliases persist after publish and reload', async ({
test('Published search aliases remain searchable after reload', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
@@ -89,10 +76,9 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
const blueprintName = `test-persist-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
// Reload the page to ensure aliases are persisted
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].extensionManager
() => window.app && window.app.extensionManager
)
await comfyPage.nextFrame()

View File

@@ -1,433 +1,87 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import {
getPromotedWidgets,
getPromotedWidgetNames,
getPromotedWidgetCount
} from '@e2e/helpers/promotedWidgets'
const expectPromotedWidgetsToResolveToInteriorNodes = async (
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) => {
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
for (const exists of results) {
expect(exists).toBe(true)
}
}
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test.describe('Deterministic proxyWidgets Hydrate', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
await expect(beforeReload).toBeVisible()
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await comfyPage.subgraph.serializeAndReload()
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
await comfyPage.subgraph.serializeAndReload()
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterFirst
)
await comfyPage.subgraph.serializeAndReload()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(afterReload).toHaveCount(1)
await expect(afterReload).toBeVisible()
})
test.describe('Legacy And Round-Trip Coverage', () => {
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.some(([, widgetName]) => widgetName === 'batch_size')).toBe(
true
)
})
test('Duplicate ID remap workflow remains navigable after a full reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test.describe('Legacy prefixed proxyWidget normalization', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
).toBe(false)
expect(
promotedWidgets.some(
([interiorNodeId, widgetName]) =>
interiorNodeId !== '-1' && widgetName === 'batch_size'
)
).toBe(true)
})
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
await comfyPage.subgraph.serializeAndReload()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
await comfyPage.subgraph.serializeAndReload()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
await comfyPage.nextFrame()
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
expect(subgraphNodeIds.length).toBeGreaterThan(1)
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
})
test.describe('Duplicate ID Remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
// TODO: Extract allNodeIds accessor into LGraph
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})
test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]
const isNonNegative = (id: number | string) =>
typeof id === 'number' && id >= 0
return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
isNonNegative(link.origin_id) &&
!g._nodes_by_id[link.origin_id] &&
`${label}: origin_id ${link.origin_id} not found`,
isNonNegative(link.target_id) &&
!g._nodes_by_id[link.target_id] &&
`${label}: target_id ${link.target_id} not found`
].filter(Boolean)
)
)
})
expect(invalidLinks).toEqual([])
})
test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
/**
* 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'] },
() => {
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page
)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
comfyExpect(warnings).toEqual([])
})
test('Promoted widget renders with normalized name, not legacy prefix', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// The promoted widget should render with the clean name "string_a",
// not the legacy-prefixed "6: 3: string_a".
const promotedWidget = outerNode
.getByLabel('string_a', { exact: true })
.first()
await expect(promotedWidget).toBeVisible()
})
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// Both widget rows should be valid "string_a" widgets — no stale
// "Disconnected" placeholders from unresolved legacy entries.
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
})
test('Promoted widget value is editable as a text input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
})
}
)
})

View File

@@ -5,20 +5,18 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import {
expectSlotsWithinBounds,
measureNodeSlotOffsets
} from '@e2e/fixtures/utils/slotBoundsUtil'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const RENAMED_NAME = 'renamed_slot_name'
const RENAMED_SLOT_NAME = 'renamed_slot_name'
const SECOND_RENAMED_NAME = 'second_renamed_name'
const RENAMED_LABEL = 'my_seed'
// Common selectors
const SELECTORS = {
promptDialog: '.graphdialog input'
} as const
@@ -32,7 +30,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
)
})
test.describe('I/O Slot CRUD', () => {
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -83,8 +81,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('input')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -103,8 +99,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('output')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -112,10 +106,8 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount - 1)
})
})
test.describe('Slot Rename', () => {
test('Can rename I/O slots via right-click context menu', async ({
test('Can rename an input slot from the context menu', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -129,13 +121,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -239,26 +228,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
// Use direct pointer event approach to double-click on label
await comfyPage.page.evaluate(() => {
const app = window.app!
const graph = app.canvas.graph
if (!graph || !('inputNode' in graph)) {
if (!graph || !('inputNode' in graph))
throw new Error('Expected to be in subgraph')
}
const input = graph.inputs?.[0]
if (!input?.labelPos) {
if (!input?.labelPos)
throw new Error('Could not get label position for testing')
}
// Use labelPos for more precise clicking on the text
const testX = input.labelPos[0]
const testY = input.labelPos[1]
// Create a minimal mock event with required properties
// Full PointerEvent creation is unnecessary for this test
const leftClickEvent = {
canvasX: testX,
canvasY: testY,
canvasX: input.labelPos[0],
canvasY: input.labelPos[1],
button: 0,
preventDefault: () => {},
stopPropagation: () => {}
@@ -272,7 +252,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
@@ -281,25 +260,21 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
const labelClickRenamedName = 'label_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Slot Rename Dialog', () => {
test.describe('Subgraph Slot Rename Dialog', () => {
test('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
@@ -308,39 +283,27 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial slot label
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
if (initialInputLabel === null) {
throw new Error(
'Expected subgraph to have an input slot label for rightClickInputSlot'
)
}
// First rename
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the rename worked
const afterFirstRename = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph))
@@ -352,42 +315,29 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_NAME)
expect(afterFirstRename.label).toBe(RENAMED_SLOT_NAME)
// Now rename again - this is where the bug would show
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(
SELECTORS.promptDialog
)
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
// Complete the second rename to ensure everything still works
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the second rename worked
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
})
@@ -400,173 +350,193 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial output slot label
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
if (initialOutputLabel === null) {
throw new Error(
'Expected subgraph to have an output slot label for rightClickOutputSlot'
)
}
// First rename
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Now rename again to check for stale content
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.subgraph.rightClickOutputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(
SELECTORS.promptDialog
)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
})
})
test.describe('Slot Rename Propagation', () => {
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe('Subgraph input slot rename propagation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNode).toBeVisible()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
const seedWidget = subgraphNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
// Verify widget is in the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const seedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
const inputs = (graph as { inputs?: Array<{ name: string }> }).inputs
return (
inputs?.find((input) => input.name.includes('seed'))?.name ?? null
)
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = SELECTORS.promptDialog
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_LABEL)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNodeAfter).toBeVisible()
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const updatedLabel = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
const widget = node.widgets?.find((entry: { name: string }) =>
entry.name.includes('seed')
)
return w?.label || w?.name || null
return widget?.label || widget?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await comfyExpect(seedWidgetAfter).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
const seedWidgetAfter = subgraphNodeAfter.getByLabel('seed', {
exact: true
})
await expect(seedWidgetAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByText(RENAMED_LABEL, { exact: true })
).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(
subgraphNodeAfter,
seedWidgetAfter
)
})
})
test.describe('Compressed target_slot', () => {
test('Can create widget from link with compressed target_slot', async ({
test.describe('Subgraph promoted widget-input slot position', () => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
'subgraphs/subgraph-with-promoted-text-widget'
)
const step = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes[0].widgets![0].options.step
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(input: { type: string }) => input.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
expect(step).toBe(10)
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
expect(after!.widgetName).toBe(before!.widgetName)
})
})
test.describe('Slot Alignment', () => {
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test.describe('Subgraph slot alignment after LG layout scale', () => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
@@ -610,91 +580,4 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
}
})
})
test.describe('Promoted Slot Position', () => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = SELECTORS.promptDialog
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
})
})

View File

@@ -0,0 +1,66 @@
---
globs:
- 'src/components/**/*.vue'
- 'src/views/**/*.vue'
---
# Comfy Design Standards
Applies when implementing or modifying user-facing components and views.
## Before Implementing UI Changes
Consult the **Comfy Design Standards** Figma file to ensure your changes follow the agreed-upon design principles. Use the Figma MCP tool to fetch the current standards:
```javascript
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '0:1' })
```
The Figma file is the single source of truth. Always fetch it live — do not rely on cached assumptions.
> **Note:** The Figma MCP is read-only. It cannot detect changes or diffs between versions. Always fetch the latest state before implementing.
### Key Sections
| Section | Node ID | When to consult |
| -------------------- | --------- | ----------------------------------------------------------------------------- |
| Hover States | `1:2` | Adding/modifying interactive elements (buttons, inputs, links, nav items) |
| Click Targets | `4:243` | Adding clickable elements, especially small ones (icons, handles, connectors) |
| Affordances | `15:2202` | Any interactive element — ensuring visual feedback on interaction |
| Feedback | `15:2334` | User actions that need confirmation, success/error states |
| Slips and How to Fix | `15:2337` | Error prevention, undo patterns, destructive actions |
| Design Pillars | `15:2340` | New features, architectural UI decisions |
| The User | `16:2348` | User flows, onboarding, accessibility |
Fetch the specific section relevant to your task:
```javascript
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '<node-id>' })
```
## Figma Component Reference
The Figma file contains component specifications. When implementing these components, fetch details to match the design:
| Component | Component Set ID |
| ----------------- | ---------------- |
| Button/Default | `4:314` |
| Search | `4:2366` |
| Base Node Example | `4:4739` |
## Figma Token Translation Rules
When translating Figma design tokens into code:
- **Skip `-hover` and `-selected` suffixed tokens.** These states exist in Figma only for prototype demonstrations. On the frontend, hover and selected states must be derived programmatically (e.g., via `color-mix()` or Tailwind modifier classes like `hover:`).
- **Color tier system:** Figma uses a tiered color hierarchy:
- **Base** — default surface/background colors
- **Secondary** — elevated surfaces (e.g., sidebars, cards)
- **Tertiary** — elements on modal panels (one shade lighter than base)
- Map Figma token names directly to Tailwind 4 semantic tokens — never hardcode hex values.
## Integration with Codebase
- Map Figma color values to Tailwind 4 semantic tokens — never hardcode hex values
- Use `cn()` from `@/utils/tailwindUtil` for conditional class merging
- Use the `dark:` avoidance rule from AGENTS.md — semantic tokens handle both themes

View File

@@ -1,6 +1,7 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
treatConfigHintsAsErrors: true,
workspaces: {
'.': {
entry: [
@@ -33,11 +34,9 @@ const config: KnipConfig = {
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.ts'
'src/styles/global.css'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
}
},
ignoreBinaries: ['python3'],
@@ -54,8 +53,6 @@ const config: KnipConfig = {
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/ingest-types/src/zod.gen.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR

627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.55.0
eslint-plugin-oxlint: 1.59.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
@@ -89,14 +89,14 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.0.1
knip: ^6.3.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.6.1
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -32,21 +32,14 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] text-xs" />
<i class="icon-[lucide--trash-2]" />
</button>
<button
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
</button>
</div>
</div>
@@ -115,7 +108,7 @@ const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const ACTION_BTN_CLASS =
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

View File

@@ -260,8 +260,26 @@ function handleColorSelect(subOption: SubMenuOption) {
hide()
}
function constrainMenuHeight() {
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const rootList = menuInstance?.container?.querySelector(
':scope > ul'
) as HTMLElement | null
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
}
function onMenuShow() {
isOpen.value = true
requestAnimationFrame(constrainMenuHeight)
}
function onMenuHide() {

View File

@@ -25,7 +25,7 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] size-3.5" />
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
variant="muted-textonly"
@@ -33,7 +33,7 @@
:aria-label="$t('g.edit')"
@click.stop="editBlueprint"
>
<i class="icon-[lucide--square-pen] size-3.5" />
<i class="icon-[lucide--square-pen] size-4" />
</Button>
</template>
<template v-else #actions>

View File

@@ -9,6 +9,7 @@ import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
const PASTED_IMAGE_EXPIRY_MS = 2000
const UPLOAD_TIMEOUT_MS = 120_000
interface ImageUploadFormFields {
/**
@@ -30,7 +31,8 @@ const uploadFile = async (
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
body,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
})
if (resp.status !== 200) {
@@ -88,7 +90,11 @@ export const useNodeImageUpload = (
if (!path) return
return path
} catch (error) {
useToastStore().addAlert(String(error))
if (error instanceof DOMException && error.name === 'TimeoutError') {
useToastStore().addAlert(t('g.uploadTimedOut'))
} else {
useToastStore().addAlert(String(error))
}
}
}

View File

@@ -123,7 +123,8 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs

View File

@@ -0,0 +1,138 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
const mockToastAdd = vi.fn()
const mockToastRemove = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd,
remove: mockToastRemove
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const settingMocks = vi.hoisted(() => ({
disableToast: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Toast.DisableReconnectingToast')
return settingMocks.disableToast
return undefined
})
}))
}))
describe('useReconnectingNotification', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.clearAllMocks()
settingMocks.disableToast = false
})
afterEach(() => {
vi.useRealTimers()
})
it('does not show toast immediately on reconnecting', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('shows error toast after delay', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
})
it('suppresses toast when reconnected before delay expires', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(500)
onReconnected()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('removes toast and shows success when reconnected after delay', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
mockToastAdd.mockClear()
onReconnected()
expect(mockToastRemove).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'g.reconnected',
life: 2000
})
)
})
it('does nothing when toast is disabled via setting', () => {
settingMocks.disableToast = true
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('does nothing when onReconnected is called without prior onReconnecting', () => {
const { onReconnected } = useReconnectingNotification()
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('handles multiple reconnecting events without duplicating toasts', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500) // first toast fires
onReconnecting() // second reconnecting event
vi.advanceTimersByTime(1500) // second toast fires
expect(mockToastAdd).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,52 @@
import { useTimeoutFn } from '@vueuse/core'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const RECONNECT_TOAST_DELAY_MS = 1500
export function useReconnectingNotification() {
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('g.reconnecting')
}
const reconnectingToastShown = ref(false)
const { start, stop } = useTimeoutFn(
() => {
toast.add(reconnectingMessage)
reconnectingToastShown.value = true
},
RECONNECT_TOAST_DELAY_MS,
{ immediate: false }
)
function onReconnecting() {
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
start()
}
function onReconnected() {
stop()
if (reconnectingToastShown.value) {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('g.reconnected'),
life: 2000
})
reconnectingToastShown.value = false
}
}
return { onReconnecting, onReconnected }
}

View File

@@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeDefined()
expect(
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})

View File

@@ -0,0 +1,103 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste'
class TestCustomComboNode extends LGraphNode {
static override title = 'CustomCombo'
constructor() {
super('CustomCombo')
this.serialize_widgets = true
this.addOutput('value', '*')
this.addWidget('combo', 'value', '', () => {}, {
values: [] as string[]
})
}
}
function findWidget(node: LGraphNode, name: string) {
return node.widgets?.find((widget) => widget.name === name)
}
function getCustomWidgetsExtension(): ComfyExtension {
const extension = useExtensionStore().extensions.find(
(candidate) => candidate.name === 'Comfy.CustomWidgets'
)
if (!extension) {
throw new Error('Comfy.CustomWidgets extension was not registered')
}
return extension
}
describe('CustomCombo copy/paste', () => {
beforeAll(async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
await import('./customWidgets')
const extension = getCustomWidgetsExtension()
await extension.beforeRegisterNodeDef?.(
TestCustomComboNode,
{ name: 'CustomCombo' } as ComfyNodeDef,
app
)
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode)
})
afterAll(() => {
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
})
it('preserves combo options and selected value through clone and paste', () => {
const graph = new LGraph()
type AppWithRootGraph = { rootGraphInternal?: LGraph }
const appWithRootGraph = app as unknown as AppWithRootGraph
const previousRootGraph = appWithRootGraph.rootGraphInternal
appWithRootGraph.rootGraphInternal = graph
try {
const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
graph.add(original)
findWidget(original, 'option1')!.value = 'alpha'
findWidget(original, 'option2')!.value = 'beta'
findWidget(original, 'option3')!.value = 'gamma'
findWidget(original, 'value')!.value = 'beta'
const clonedSerialised = original.clone()?.serialize()
expect(clonedSerialised).toBeDefined()
const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
pasted.configure(clonedSerialised!)
graph.add(pasted)
expect(findWidget(pasted, 'value')!.value).toBe('beta')
expect(findWidget(pasted, 'option1')!.value).toBe('alpha')
expect(findWidget(pasted, 'option2')!.value).toBe('beta')
expect(findWidget(pasted, 'option3')!.value).toBe('gamma')
expect(findWidget(pasted, 'value')!.options.values).toEqual([
'alpha',
'beta',
'gamma'
])
} finally {
appWithRootGraph.rootGraphInternal = previousRootGraph
}
})
})

View File

@@ -63,7 +63,7 @@ function onCustomComboCreated(this: LGraphNode) {
(w) => w.name.startsWith('option') && w.value
).map((w) => `${w.value}`)
)
if (app.configuringGraph) return
if (app.configuringGraph || !this.graph) return
if (values.includes(`${comboWidget.value}`)) return
comboWidget.value = values[0] ?? ''
comboWidget.callback?.(comboWidget.value)
@@ -71,6 +71,9 @@ function onCustomComboCreated(this: LGraphNode) {
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
this.applyToGraph!()
)
this.onAdded = useChainCallback(this.onAdded, function () {
updateCombo()
})
function addOption(node: LGraphNode) {
if (!node.widgets) return
@@ -78,16 +81,17 @@ function onCustomComboCreated(this: LGraphNode) {
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let localValue = `${widget.value ?? ''}`
Object.defineProperty(widget, 'value', {
get() {
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,

View File

@@ -143,9 +143,10 @@ app.registerExtension({
throw new Error(err)
}
const data = await resp.json()
const serverName = data.name ?? name
const subfolder = data.subfolder ?? 'webcam'
return `${subfolder}/${serverName} [temp]`
const serverName = data.name || name
const subfolder = data.subfolder || 'webcam'
const type = data.type || 'temp'
return `${subfolder}/${serverName} [${type}]`
}
// @ts-expect-error fixme ts strict error

View File

@@ -11,6 +11,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
@@ -988,21 +990,43 @@ describe('SubgraphNode label propagation', () => {
})
it('should propagate label via renaming-input event', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps', type: 'number' }]
})
subgraph.addInput('steps', 'number')
expect(subgraphNode.inputs[0].label).toBeUndefined()
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'number')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'number')
interiorNode.addWidget('number', 'value', 0, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const originalWidgetName = promotedInput.widget?.name
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
expect(promotedInput.label).toBeUndefined()
expect(promotedInput._widget).toBeDefined()
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
expect(subgraphNode.inputs[0].name).toBe('steps')
expect(promotedInput.label).toBe('Steps Count')
expect(promotedInput.name).toBe('steps')
expect(promotedInput.widget?.name).toBe(originalWidgetName)
expect(promotedInput._widget?.label).toBe('Steps Count')
expect(subgraphNode.widgets?.[0].label).toBe('Steps Count')
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
nodeId: subgraphNode.id,
slotType: NodeSlotType.INPUT
})
})
it('should propagate label via renaming-output event', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
subgraph.addOutput('result', 'number')
expect(subgraphNode.outputs[0].label).toBeUndefined()
@@ -1011,6 +1035,10 @@ describe('SubgraphNode label propagation', () => {
expect(subgraphNode.outputs[0].label).toBe('Final Result')
expect(subgraphNode.outputs[0].name).toBe('result')
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
nodeId: subgraphNode.id,
slotType: NodeSlotType.OUTPUT
})
})
it('should preserve localized_name from configure path', () => {

View File

@@ -4,10 +4,11 @@
* Tests for saving, loading, and version compatibility of subgraphs.
* This covers serialization, deserialization, data integrity, and migration scenarios.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { duplicateSubgraphNodeIds } from '@/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds'
import {
LGraph,
LGraphNode,
@@ -22,6 +23,11 @@ import {
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
class DummyNode extends LGraphNode {}
const DUPLICATE_ID_SUBGRAPH_A = '11111111-1111-4111-8111-111111111111'
const DUPLICATE_ID_SUBGRAPH_B = '22222222-2222-4222-8222-222222222222'
function createRegisteredNode(
graph: LGraph | Subgraph,
inputs: ISlotType[] = [],
@@ -50,6 +56,11 @@ function createRegisteredNode(
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
LiteGraph.registerNodeType('dummy', DummyNode)
})
afterEach(() => {
delete LiteGraph.registered_node_types.dummy
})
describe('SubgraphSerialization - Basic Serialization', () => {
@@ -475,4 +486,57 @@ describe('SubgraphSerialization - Data Integrity', () => {
expect(targetNode!.inputs[link.target_slot]).toBeDefined()
}
})
it('deduplicates duplicate subgraph node IDs while keeping root nodes canonical', () => {
const graph = new LGraph()
graph.configure(structuredClone(duplicateSubgraphNodeIds))
const rootIds = graph.nodes
.map((node) => node.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
expect(rootIds).toEqual([102, 103])
const subgraphAIds = new Set(
graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_A)!.nodes.map((node) => node.id)
)
const subgraphBIds = new Set(
graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!.nodes.map((node) => node.id)
)
expect(subgraphAIds).toEqual(new Set([3, 8, 37]))
for (const id of subgraphAIds) {
expect(subgraphBIds.has(id)).toBe(false)
}
})
it('patches remapped link and proxyWidget references during duplicate-ID hydration', () => {
const graph = new LGraph()
graph.configure(structuredClone(duplicateSubgraphNodeIds))
const subgraphAIds = new Set(
graph.subgraphs
.get(DUPLICATE_ID_SUBGRAPH_A)!
.nodes.map((node) => String(node.id))
)
const subgraphB = graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!
const subgraphBIds = new Set(subgraphB.nodes.map((node) => String(node.id)))
const rootProxyWidgetsA = graph.getNodeById(102)?.properties?.proxyWidgets
expect(Array.isArray(rootProxyWidgetsA)).toBe(true)
for (const entry of rootProxyWidgetsA as string[][]) {
expect(subgraphAIds.has(String(entry[0]))).toBe(true)
}
const rootProxyWidgetsB = graph.getNodeById(103)?.properties?.proxyWidgets
expect(Array.isArray(rootProxyWidgetsB)).toBe(true)
for (const entry of rootProxyWidgetsB as string[][]) {
expect(subgraphBIds.has(String(entry[0]))).toBe(true)
}
for (const [, link] of subgraphB.links) {
expect(subgraphBIds.has(String(link.origin_id))).toBe(true)
expect(subgraphBIds.has(String(link.target_id))).toBe(true)
}
})
})

View File

@@ -266,6 +266,40 @@ describe('SubgraphWidgetPromotion', () => {
})
describe('Nested Subgraph Widget Promotion', () => {
it('should hydrate legacy -1 proxyWidgets to a concrete promoted widget with preserved options', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'batch_size', type: 'INT' }]
})
const interiorNode = new LGraphNode('EmptyLatentImage')
const interiorInput = interiorNode.addInput('batch_size', 'INT')
interiorNode.addOutput('LATENT', 'LATENT')
interiorNode.addWidget('number', 'batch_size', 1, () => {}, {
step: 10,
min: 1
})
interiorInput.widget = { name: 'batch_size' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
const serializedHostNode = hostNode.serialize()
serializedHostNode.properties = {
...serializedHostNode.properties,
proxyWidgets: [['-1', 'batch_size']]
}
hostNode.configure(serializedHostNode)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(interiorNode.id), 'batch_size']
])
expect(hostNode.widgets).toHaveLength(1)
expect(hostNode.widgets[0].name).toBe('batch_size')
expect(hostNode.widgets[0].value).toBe(1)
expect(hostNode.widgets[0].options.step).toBe(10)
})
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
// Reproduces the bug where packing nodes into a nested subgraph leaves
// stale proxyWidgets on the outer subgraph node referencing grandchild
@@ -378,6 +412,47 @@ describe('SubgraphWidgetPromotion', () => {
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
])
})
it('should preserve promoted widget entries after cloning', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('CLIPTextEncode')
const interiorInput = interiorNode.addInput('text', 'STRING')
interiorNode.addOutput('CONDITIONING', 'CONDITIONING')
interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: 'text' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
// serialize() syncs the promotion store into properties.proxyWidgets
const serialized = hostNode.serialize()
const originalProxyWidgets = serialized.properties!
.proxyWidgets as string[][]
expect(originalProxyWidgets.length).toBeGreaterThan(0)
expect(
originalProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
// Simulate clone: create a second SubgraphNode configured from serialized data
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
expect(
cloneProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
// Clone's proxyWidgets should reference the same interior node
const originalNodeIds = originalProxyWidgets.map(([nodeId]) => nodeId)
const cloneNodeIds = cloneProxyWidgets.map(([nodeId]) => nodeId)
expect(cloneNodeIds).toStrictEqual(originalNodeIds)
})
})
describe('Tooltip Promotion', () => {

View File

@@ -199,6 +199,7 @@
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"uploadAlreadyInProgress": "Upload already in progress",
"uploadTimedOut": "Upload timed out. Please try again.",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} node | {count} nodes",

View File

@@ -121,7 +121,7 @@ describe('GtmTelemetryProvider', () => {
event: 'execution_error',
node_type: 'KSampler'
})
expect((entry?.error as string).length).toBe(100)
expect(entry!.error as string).toHaveLength(100)
})
it('pushes select_content for template events', () => {

View File

@@ -0,0 +1,80 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import LinearWelcome from './LinearWelcome.vue'
const hasNodes = ref(false)
const hasOutputs = ref(false)
const enterBuilder = vi.fn()
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: vi.fn() })
}))
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: () => ({ show: vi.fn() })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
hasNodes,
hasOutputs,
enterBuilder
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function mountComponent(
opts: { hasNodes?: boolean; hasOutputs?: boolean } = {}
) {
hasNodes.value = opts.hasNodes ?? false
hasOutputs.value = opts.hasOutputs ?? false
return mount(LinearWelcome, {
global: { plugins: [i18n] }
})
}
describe('LinearWelcome', () => {
beforeEach(() => {
hasNodes.value = false
hasOutputs.value = false
vi.clearAllMocks()
})
it('shows empty workflow text when there are no nodes', () => {
const wrapper = mountComponent({ hasNodes: false })
expect(
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(true)
expect(
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(false)
})
it('shows build app button when there are nodes but no outputs', () => {
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
expect(
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(false)
expect(
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(true)
})
it('clicking build app button calls enterBuilder', async () => {
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
await wrapper
.find('[data-testid="linear-welcome-build-app"]')
.trigger('click')
expect(enterBuilder).toHaveBeenCalled()
})
})

View File

@@ -50,7 +50,11 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</p>
</div>
<template v-else>
<p v-if="!hasNodes" class="mt-0 max-w-md text-sm text-base-foreground">
<p
v-if="!hasNodes"
data-testid="linear-welcome-empty-workflow"
class="mt-0 max-w-md text-sm text-base-foreground"
>
{{ t('linearMode.emptyWorkflowExplanation') }}
</p>
<p
@@ -66,11 +70,17 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</i18n-t>
</p>
<div class="flex flex-row gap-2">
<Button variant="textonly" size="lg" @click="setMode('graph')">
<Button
data-testid="linear-welcome-back-to-workflow"
variant="textonly"
size="lg"
@click="setMode('graph')"
>
{{ t('linearMode.backToWorkflow') }}
</Button>
<Button
v-if="!hasNodes"
data-testid="linear-welcome-load-template"
variant="secondary"
size="lg"
@click="templateSelectorDialog.show('appbuilder')"
@@ -79,6 +89,7 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</Button>
<Button
v-else
data-testid="linear-welcome-build-app"
variant="primary"
size="lg"
@click="appModeStore.enterBuilder()"

View File

@@ -46,6 +46,7 @@
v-if="!videoError"
:src="currentVideoUrl"
:class="cn('block size-full object-contain', showLoader && 'invisible')"
preload="metadata"
controls
loop
playsinline

View File

@@ -5,6 +5,7 @@
:key="index"
:src="url"
controls
preload="metadata"
class="w-full"
/>
</div>

View File

@@ -2,7 +2,10 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.content', 'Node Content Error') }}
</div>
<div v-else class="lg-node-content flex flex-auto grow flex-col">
<div
v-else
class="lg-node-content flex flex-auto grow flex-col [content-visibility:auto]"
>
<!-- Default slot for custom content -->
<slot>
<VideoPreview

View File

@@ -4,7 +4,6 @@ import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type {
@@ -51,7 +50,6 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -211,7 +209,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -4,7 +4,6 @@ import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type {
FilterOption,
OwnershipFilterOption,
@@ -16,7 +15,6 @@ import FormSearchInput from '../FormSearchInput.vue'
import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
defineProps<{
sortOptions: SortOption[]
@@ -135,7 +133,6 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -198,7 +195,6 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="ownershipPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -261,7 +257,6 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="baseModelPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -28,6 +28,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
const mockAssetsStoreState = vi.hoisted(() => {
const inputAssets: AssetItem[] = []
return {
@@ -55,7 +56,8 @@ vi.mock('@/stores/assetsStore', () => ({
return mockAssetsStoreState.inputLoading
},
updateInputs: mockUpdateInputs,
getInputName: mockGetInputName
getInputName: mockGetInputName,
getAssets: mockGetAssets
}))
}))
@@ -199,67 +201,117 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when API enabled', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
describe('cloud asset browser widget', () => {
// "Select model" is the fallback from t('widgets.selectModel')
// in createAssetWidget when defaultValue is undefined.
const PLACEHOLDER = 'Select model'
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
function setupCloudAssetWidget(
inputSpecOverrides: Partial<InputSpec> = {}
) {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: ''
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
...inputSpecOverrides
})
constructor(mockNode, inputSpec)
return { mockNode }
}
function getWidgetDefault(mockNode: ReturnType<typeof createMockNode>) {
return vi.mocked(mockNode.addWidget).mock.calls[0]?.[2]
}
it('should create asset browser widget when API enabled', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
options: ['model1.safetensors', 'model2.safetensors']
})
expect(
vi.mocked(assetService.shouldUseAssetBrowser)
).toHaveBeenCalledWith('CheckpointLoaderSimple', 'ckpt_name')
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
expect.anything(),
expect.any(Function),
expect.any(Object)
)
})
const widget = constructor(mockNode, inputSpec)
it('should use first cloud asset as default instead of server combo options', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(vi.mocked(assetService.shouldUseAssetBrowser)).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'ckpt_name'
)
expect(widget).toBe(mockWidget)
})
const { mockNode } = setupCloudAssetWidget({
options: ['local_only_model.safetensors']
})
it('should create asset browser widget when default value provided without options', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'fallback.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
default: 'fallback.safetensors'
// Note: no options array provided
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
})
const widget = constructor(mockNode, inputSpec)
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(widget).toBe(mockWidget)
const { mockNode } = setupCloudAssetWidget({
default: 'not_in_cloud.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
})
it('should prefer inputSpec.default when it exists in cloud assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'other_model.safetensors' }),
createMockAssetItem({ name: 'fallback.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('fallback.safetensors')
})
it('should create asset browser widget when default value provided without options', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
it('should fallback to placeholder when cloud assets not loaded', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
options: ['local_model.safetensors']
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
})
it('should show Select model when asset widget has undefined current value', () => {

View File

@@ -6,6 +6,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { isCloud } from '@/platform/distribution/types'
import type {
@@ -104,6 +105,25 @@ const addMultiSelectWidget = (
return widget
}
/**
* Resolve the default value for a cloud asset widget.
* Priority: inputSpec.default (if present in cloud assets) → first cloud
* asset → undefined (shows placeholder).
*/
function resolveCloudDefault(
nodeType: string,
specDefault: string | undefined
): string | undefined {
const assets = useAssetsStore().getAssets(nodeType)
if (specDefault != null) {
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
if (inAssets) return specDefault
}
// empty filename → undefined (shows placeholder)
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
return filename || undefined
}
function createAssetBrowserWidget(
node: LGraphNode,
inputSpec: ComboInputSpec,
@@ -195,7 +215,14 @@ const addComboWidget = (
if (isCloud) {
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
return createAssetBrowserWidget(node, inputSpec, defaultValue)
// Default from cloud assets, not from server combo options.
// Server options list local files that may not exist in the user's
// cloud asset library, leading to missing-model errors on undo/reload.
const cloudDefault = resolveCloudDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {

View File

@@ -17,12 +17,12 @@ interface AutogrowGroup {
prefix?: string
}
export interface UniformSource {
interface UniformSource {
nodeId: NodeId
widgetName: string
}
export interface UniformSources {
interface UniformSources {
floats: UniformSource[]
ints: UniformSource[]
bools: UniformSource[]

View File

@@ -234,6 +234,163 @@ describe('API Feature Flags', () => {
})
})
describe('progress_text binary message parsing', () => {
/**
* Build a legacy progress_text binary message:
* [4B event_type=3][4B node_id_len][node_id_bytes][text_bytes]
*/
function buildLegacyProgressText(
nodeId: string,
text: string
): ArrayBuffer {
const encoder = new TextEncoder()
const nodeIdBytes = encoder.encode(nodeId)
const textBytes = encoder.encode(text)
const buf = new ArrayBuffer(4 + 4 + nodeIdBytes.length + textBytes.length)
const view = new DataView(buf)
view.setUint32(0, 3) // event type
view.setUint32(4, nodeIdBytes.length)
new Uint8Array(buf, 8, nodeIdBytes.length).set(nodeIdBytes)
new Uint8Array(buf, 8 + nodeIdBytes.length, textBytes.length).set(
textBytes
)
return buf
}
/**
* Build a new-format progress_text binary message:
* [4B event_type=3][4B prompt_id_len][prompt_id_bytes][4B node_id_len][node_id_bytes][text_bytes]
*/
function buildNewProgressText(
promptId: string,
nodeId: string,
text: string
): ArrayBuffer {
const encoder = new TextEncoder()
const promptIdBytes = encoder.encode(promptId)
const nodeIdBytes = encoder.encode(nodeId)
const textBytes = encoder.encode(text)
const buf = new ArrayBuffer(
4 + 4 + promptIdBytes.length + 4 + nodeIdBytes.length + textBytes.length
)
const view = new DataView(buf)
let offset = 0
view.setUint32(offset, 3) // event type
offset += 4
view.setUint32(offset, promptIdBytes.length)
offset += 4
new Uint8Array(buf, offset, promptIdBytes.length).set(promptIdBytes)
offset += promptIdBytes.length
view.setUint32(offset, nodeIdBytes.length)
offset += 4
new Uint8Array(buf, offset, nodeIdBytes.length).set(nodeIdBytes)
offset += nodeIdBytes.length
new Uint8Array(buf, offset, textBytes.length).set(textBytes)
return buf
}
let dispatchedEvents: { nodeId: string; text: string; prompt_id?: string }[]
let listener: EventListener
beforeEach(async () => {
dispatchedEvents = []
listener = ((e: CustomEvent) => {
dispatchedEvents.push(e.detail)
}) as EventListener
api.addEventListener('progress_text', listener)
// Connect the WebSocket so the message handler is active
const initPromise = api.init()
wsEventHandlers['open'](new Event('open'))
wsEventHandlers['message']({
data: JSON.stringify({
type: 'status',
data: {
status: { exec_info: { queue_remaining: 0 } },
sid: 'test-sid'
}
})
})
await initPromise
})
afterEach(() => {
api.removeEventListener('progress_text', listener)
})
it('should parse legacy format when server does not support progress_text_metadata', () => {
// Restore real getClientFeatureFlags (advertises support)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_progress_text_metadata: true
})
// Server does NOT echo support
api.serverFeatureFlags.value = {}
const msg = buildLegacyProgressText('42', 'Generating image...')
wsEventHandlers['message']({ data: msg })
expect(dispatchedEvents).toHaveLength(1)
expect(dispatchedEvents[0]).toEqual({
nodeId: '42',
text: 'Generating image...'
})
})
it('should parse new format when server supports progress_text_metadata', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_progress_text_metadata: true
})
api.serverFeatureFlags.value = {
supports_progress_text_metadata: true
}
const msg = buildNewProgressText('prompt-abc', '42', 'Step 5/20')
wsEventHandlers['message']({ data: msg })
expect(dispatchedEvents).toHaveLength(1)
expect(dispatchedEvents[0]).toEqual({
nodeId: '42',
text: 'Step 5/20',
prompt_id: 'prompt-abc'
})
})
it('should not corrupt legacy messages when client advertises support but server does not', () => {
// This is the exact bug scenario: client says it supports the flag,
// server doesn't, but the decoder checks the client flag and tries
// to parse a prompt_id that isn't there.
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_progress_text_metadata: true
})
api.serverFeatureFlags.value = {}
// Send multiple legacy messages to ensure none are corrupted
const messages = [
buildLegacyProgressText('7', 'Loading model...'),
buildLegacyProgressText('12', 'Sampling 3/20'),
buildLegacyProgressText('99', 'VAE decode')
]
for (const msg of messages) {
wsEventHandlers['message']({ data: msg })
}
expect(dispatchedEvents).toHaveLength(3)
expect(dispatchedEvents[0]).toMatchObject({
nodeId: '7',
text: 'Loading model...'
})
expect(dispatchedEvents[1]).toMatchObject({
nodeId: '12',
text: 'Sampling 3/20'
})
expect(dispatchedEvents[2]).toMatchObject({
nodeId: '99',
text: 'VAE decode'
})
})
})
describe('Dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -638,7 +638,7 @@ export class ComfyApi extends EventTarget {
let promptId: string | undefined
if (
this.getClientFeatureFlags()?.supports_progress_text_metadata
this.serverFeatureFlags.value?.supports_progress_text_metadata
) {
const promptIdLength = rawView.getUint32(offset)
offset += 4

View File

@@ -67,6 +67,7 @@ vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
describe('useSubgraphNavigationStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
app.rootGraph.subgraphs.clear()
app.canvas.subgraph = undefined
app.canvas.ds.scale = 1
@@ -74,7 +75,6 @@ describe('useSubgraphNavigationStore', () => {
app.canvas.ds.state.scale = 1
app.canvas.ds.state.offset = [0, 0]
app.graph.getNodeById = vi.fn()
vi.resetAllMocks()
})
it('should not clear navigation stack when workflow internal state changes', async () => {
@@ -82,11 +82,11 @@ describe('useSubgraphNavigationStore', () => {
const workflowStore = useWorkflowStore()
// Mock a workflow
const mockWorkflow = {
const mockWorkflow = fromPartial<ComfyWorkflow>({
path: 'test-workflow.json',
filename: 'test-workflow.json',
changeTracker: null
} as ComfyWorkflow
})
// Set the active workflow (cast to bypass TypeScript check in test)
workflowStore.activeWorkflow =
@@ -113,15 +113,15 @@ describe('useSubgraphNavigationStore', () => {
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
const workflow1 = {
const workflow1 = fromPartial<ComfyWorkflow>({
path: 'workflow1.json',
filename: 'workflow1.json'
} as ComfyWorkflow
})
const workflow2 = {
const workflow2 = fromPartial<ComfyWorkflow>({
path: 'workflow2.json',
filename: 'workflow2.json'
} as ComfyWorkflow
})
const sub1 = createMockSubgraph('sub-1')
const sub2 = createMockSubgraph('sub-2')
@@ -165,10 +165,10 @@ describe('useSubgraphNavigationStore', () => {
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
const workflow1 = {
const workflow1 = fromPartial<ComfyWorkflow>({
path: 'workflow1.json',
filename: 'workflow1.json'
} as ComfyWorkflow
})
const workflow1Subgraph = createMockSubgraph('sub-1')
@@ -185,10 +185,10 @@ describe('useSubgraphNavigationStore', () => {
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
const workflow2 = {
const workflow2 = fromPartial<ComfyWorkflow>({
path: 'workflow2.json',
filename: 'workflow2.json'
} as ComfyWorkflow
})
app.canvas.subgraph = undefined
@@ -216,6 +216,32 @@ describe('useSubgraphNavigationStore', () => {
expect(navigationStore.navigationStack).toEqual([])
})
it('should fall back to the active subgraph id when path lookup fails during navigation updates', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
const unreachableSubgraph = createMockSubgraph('orphan-subgraph', app.graph)
app.graph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
vi.mocked(findSubgraphPathById).mockReturnValue(null)
const mockWorkflow = fromPartial<ComfyWorkflow>({
path: 'test-workflow.json',
filename: 'test-workflow.json'
})
workflowStore.activeWorkflow =
mockWorkflow as typeof workflowStore.activeWorkflow
app.canvas.subgraph = unreachableSubgraph
workflowStore.updateActiveGraph()
await nextTick()
expect(navigationStore.exportState()).toEqual([unreachableSubgraph.id])
expect(navigationStore.navigationStack).toEqual([unreachableSubgraph])
})
it('should clear navigation when activeSubgraph becomes undefined', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
@@ -228,10 +254,10 @@ describe('useSubgraphNavigationStore', () => {
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
// First set an active workflow
const mockWorkflow = {
const mockWorkflow = fromPartial<ComfyWorkflow>({
path: 'test-workflow.json',
filename: 'test-workflow.json'
} as ComfyWorkflow
})
workflowStore.activeWorkflow =
mockWorkflow as typeof workflowStore.activeWorkflow

View File

@@ -34,8 +34,7 @@
<script setup lang="ts">
import { useEventListener, useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import {
computed,
nextTick,
@@ -45,7 +44,6 @@ import {
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
@@ -58,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
@@ -103,8 +102,6 @@ setupAutoQueueHandler()
useProgressFavicon()
useBrowserTabTitle()
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const colorPaletteStore = useColorPaletteStore()
@@ -250,28 +247,7 @@ const onExecutionSuccess = async () => {
}
}
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('g.reconnecting')
}
const onReconnecting = () => {
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
toast.remove(reconnectingMessage)
toast.add(reconnectingMessage)
}
}
const onReconnected = () => {
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('g.reconnected'),
life: 2000
})
}
}
const { onReconnecting, onReconnected } = useReconnectingNotification()
useEventListener(api, 'status', onStatus)
useEventListener(api, 'execution_success', onExecutionSuccess)